Update README.md to include project stack, development and build instructions, and details on PocketBase and shadcn-svelte integration.

This commit is contained in:
eewing
2026-02-16 16:04:14 -06:00
parent 3c1442e150
commit faae3c1a43
79 changed files with 2544 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import './app.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}
+16
View File
@@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
export interface EmployeeRecord {
id: string;
First_Name: string;
Last_Name: string;
Phone_Number: string;
Email_Address: string;
Voxer_ID: string;
Status: string;
[key: string]: unknown;
}
export const load: PageServerLoad = async () => {
return {};
};
+412
View File
@@ -0,0 +1,412 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as Table from '$lib/components/ui/table/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import type { EmployeeRecord } from './+page.server';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import PencilIcon from '@lucide/svelte/icons/pencil';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import PlusIcon from '@lucide/svelte/icons/plus';
import SearchIcon from '@lucide/svelte/icons/search';
let employees = $state<EmployeeRecord[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let searchQuery = $state('');
let openCreate = $state(false);
let openEdit = $state(false);
let openDelete = $state(false);
let editingEmployee = $state<EmployeeRecord | null>(null);
let deletingEmployee = $state<EmployeeRecord | null>(null);
let formFirst = $state('');
let formLast = $state('');
let formPhone = $state('');
let formEmail = $state('');
let formVoxer = $state('');
let formStatus = $state('');
let formSaving = $state(false);
let formError = $state<string | null>(null);
let deleteSaving = $state(false);
let deleteError = $state<string | null>(null);
async function loadEmployees() {
try {
const res = await fetch('/api/employees');
if (!res.ok) throw new Error(res.statusText);
const json = await res.json();
employees = json.employees ?? [];
error = null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load employees';
} finally {
loading = false;
}
}
function resetForm() {
formFirst = '';
formLast = '';
formPhone = '';
formEmail = '';
formVoxer = '';
formStatus = '';
formError = null;
}
function openCreateDialog() {
resetForm();
editingEmployee = null;
openCreate = true;
}
function openEditDialog(emp: EmployeeRecord) {
editingEmployee = emp;
formFirst = emp.First_Name ?? '';
formLast = emp.Last_Name ?? '';
formPhone = emp.Phone_Number ?? '';
formEmail = emp.Email_Address ?? '';
formVoxer = emp.Voxer_ID ?? '';
formStatus = emp.Status ?? '';
formError = null;
openEdit = true;
}
function openDeleteDialog(emp: EmployeeRecord) {
deletingEmployee = emp;
deleteError = null;
openDelete = true;
}
async function submitCreate() {
formSaving = true;
formError = null;
try {
const res = await fetch('/api/employees', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
First_Name: formFirst,
Last_Name: formLast,
Phone_Number: formPhone,
Email_Address: formEmail,
Voxer_ID: formVoxer,
Status: formStatus
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || res.statusText);
}
openCreate = false;
await loadEmployees();
} catch (e) {
formError = e instanceof Error ? e.message : 'Failed to create';
} finally {
formSaving = false;
}
}
async function submitEdit() {
if (!editingEmployee) return;
formSaving = true;
formError = null;
try {
const res = await fetch(`/api/employees/${editingEmployee.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
First_Name: formFirst,
Last_Name: formLast,
Phone_Number: formPhone,
Email_Address: formEmail,
Voxer_ID: formVoxer,
Status: formStatus
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || res.statusText);
}
openEdit = false;
editingEmployee = null;
await loadEmployees();
} catch (e) {
formError = e instanceof Error ? e.message : 'Failed to update';
} finally {
formSaving = false;
}
}
async function confirmDelete() {
if (!deletingEmployee) return;
deleteSaving = true;
deleteError = null;
try {
const res = await fetch(`/api/employees/${deletingEmployee.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(res.statusText);
openDelete = false;
deletingEmployee = null;
await loadEmployees();
} catch (e) {
deleteError = e instanceof Error ? e.message : 'Failed to delete';
} finally {
deleteSaving = false;
}
}
const filteredEmployees = $derived(
!searchQuery.trim()
? employees
: employees.filter((emp) => {
const q = searchQuery.toLowerCase();
return (
(emp.First_Name ?? '').toLowerCase().includes(q) ||
(emp.Last_Name ?? '').toLowerCase().includes(q) ||
(emp.Email_Address ?? '').toLowerCase().includes(q) ||
(emp.Phone_Number ?? '').toLowerCase().includes(q) ||
(emp.Voxer_ID ?? '').toLowerCase().includes(q) ||
(emp.Status ?? '').toLowerCase().includes(q)
);
})
);
onMount(loadEmployees);
</script>
<main class="flex flex-col gap-6 p-6 md:p-8">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-4">
<div class="relative min-w-0 flex-1 sm:max-w-sm">
<SearchIcon
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
/>
<Input
type="search"
placeholder="Search by name, email, phone, Voxer ID, status…"
bind:value={searchQuery}
class="pl-9"
/>
</div>
<Button onclick={openCreateDialog} class="shrink-0">
<PlusIcon class="size-4" />
Add employee
</Button>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>First Name</Table.Head>
<Table.Head>Last Name</Table.Head>
<Table.Head>Phone</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Voxer ID</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head class="w-[70px]">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">
Loading…
</Table.Cell>
</Table.Row>
{:else if error}
<Table.Row>
<Table.Cell colspan={7} class="text-destructive py-8 text-center">
{error}
</Table.Cell>
</Table.Row>
{:else}
{#each filteredEmployees as employee (employee.id)}
<Table.Row>
<Table.Cell>{employee.First_Name}</Table.Cell>
<Table.Cell>{employee.Last_Name}</Table.Cell>
<Table.Cell>{employee.Phone_Number}</Table.Cell>
<Table.Cell>{employee.Email_Address}</Table.Cell>
<Table.Cell>{employee.Voxer_ID}</Table.Cell>
<Table.Cell>{employee.Status}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex size-8 items-center justify-center rounded-md hover:bg-accent"
asChild
>
<Button variant="ghost" size="icon" class="size-8">
<EllipsisVerticalIcon class="size-4" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEditDialog(employee)}>
<PencilIcon class="size-4" />
Edit
</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
onclick={() => openDeleteDialog(employee)}
>
<Trash2Icon class="size-4" />
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground text-center">
{searchQuery.trim()
? 'No matching employees.'
: 'No employee records found.'}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</main>
<!-- Create dialog -->
<Dialog.Root bind:open={openCreate}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Add employee</Dialog.Title>
<Dialog.Description>Create a new employee record.</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
submitCreate();
}}
class="grid gap-4 py-4"
>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="create-first">First Name</Label>
<Input id="create-first" bind:value={formFirst} required />
</div>
<div class="space-y-2">
<Label for="create-last">Last Name</Label>
<Input id="create-last" bind:value={formLast} required />
</div>
</div>
<div class="space-y-2">
<Label for="create-phone">Phone</Label>
<Input id="create-phone" bind:value={formPhone} />
</div>
<div class="space-y-2">
<Label for="create-email">Email</Label>
<Input id="create-email" type="email" bind:value={formEmail} />
</div>
<div class="space-y-2">
<Label for="create-voxer">Voxer ID</Label>
<Input id="create-voxer" bind:value={formVoxer} />
</div>
<div class="space-y-2">
<Label for="create-status">Status</Label>
<Input id="create-status" bind:value={formStatus} />
</div>
{#if formError}
<p class="text-destructive text-sm">{formError}</p>
{/if}
<Dialog.Footer>
<Dialog.Close asChild>
<Button type="button" variant="outline">Cancel</Button>
</Dialog.Close>
<Button type="submit" disabled={formSaving}>
{formSaving ? 'Saving…' : 'Create'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Edit dialog -->
<Dialog.Root bind:open={openEdit}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit employee</Dialog.Title>
<Dialog.Description>Update this employee record.</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
submitEdit();
}}
class="grid gap-4 py-4"
>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="edit-first">First Name</Label>
<Input id="edit-first" bind:value={formFirst} required />
</div>
<div class="space-y-2">
<Label for="edit-last">Last Name</Label>
<Input id="edit-last" bind:value={formLast} required />
</div>
</div>
<div class="space-y-2">
<Label for="edit-phone">Phone</Label>
<Input id="edit-phone" bind:value={formPhone} />
</div>
<div class="space-y-2">
<Label for="edit-email">Email</Label>
<Input id="edit-email" type="email" bind:value={formEmail} />
</div>
<div class="space-y-2">
<Label for="edit-voxer">Voxer ID</Label>
<Input id="edit-voxer" bind:value={formVoxer} />
</div>
<div class="space-y-2">
<Label for="edit-status">Status</Label>
<Input id="edit-status" bind:value={formStatus} />
</div>
{#if formError}
<p class="text-destructive text-sm">{formError}</p>
{/if}
<Dialog.Footer>
<Dialog.Close asChild>
<Button type="button" variant="outline">Cancel</Button>
</Dialog.Close>
<Button type="submit" disabled={formSaving}>
{formSaving ? 'Saving…' : 'Save'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Delete confirmation dialog -->
<Dialog.Root bind:open={openDelete}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete employee</Dialog.Title>
<Dialog.Description>
Are you sure you want to delete
{#if deletingEmployee}
<strong>{deletingEmployee.First_Name} {deletingEmployee.Last_Name}</strong>?
{/if}
This cannot be undone.
</Dialog.Description>
</Dialog.Header>
{#if deleteError}
<p class="text-destructive text-sm">{deleteError}</p>
{/if}
<Dialog.Footer>
<Dialog.Close asChild>
<Button type="button" variant="outline">Cancel</Button>
</Dialog.Close>
<Button variant="destructive" disabled={deleteSaving} onclick={confirmDelete}>
{deleteSaving ? 'Deleting…' : 'Delete'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
logout: async ({ locals }) => {
locals.pb.authStore.clear();
throw redirect(303, '/login');
}
};
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { enhance } from '$app/forms';
</script>
<main class="flex min-h-screen flex-col items-center justify-center p-4">
<Card.Root class="w-full max-w-[400px]">
<Card.Header class="space-y-1 text-center">
<Card.Title class="text-2xl font-semibold">Access denied</Card.Title>
<Card.Description>
Your account does not have HRM access. Contact an administrator if you believe this is an error.
</Card.Description>
</Card.Header>
<Card.Content>
<form method="POST" action="?/logout" use:enhance class="space-y-4">
<Button type="submit" variant="outline" class="w-full">
Log out
</Button>
</form>
</Card.Content>
</Card.Root>
</main>
+36
View File
@@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export interface EmployeeRecord {
id: string;
First_Name: string;
Last_Name: string;
Phone_Number: string;
Email_Address: string;
Voxer_ID: string;
Status: string;
[key: string]: unknown;
}
const COLLECTION = 'Employee_Records';
export const GET: RequestHandler = async ({ locals }) => {
const { items } = await locals.pb
.collection(COLLECTION)
.getList<EmployeeRecord>(1, 500, { sort: 'Last_Name,First_Name' });
return json({ employees: items });
};
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json();
const record = await locals.pb.collection(COLLECTION).create<EmployeeRecord>({
First_Name: body.First_Name ?? '',
Last_Name: body.Last_Name ?? '',
Phone_Number: body.Phone_Number ?? '',
Email_Address: body.Email_Address ?? '',
Voxer_ID: body.Voxer_ID ?? '',
Status: body.Status ?? ''
});
return json(record);
};
+24
View File
@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import type { EmployeeRecord } from '../+server';
const COLLECTION = 'Employee_Records';
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const id = params.id;
const body = await request.json();
const record = await locals.pb.collection(COLLECTION).update<EmployeeRecord>(id, {
...(body.First_Name !== undefined && { First_Name: body.First_Name }),
...(body.Last_Name !== undefined && { Last_Name: body.Last_Name }),
...(body.Phone_Number !== undefined && { Phone_Number: body.Phone_Number }),
...(body.Email_Address !== undefined && { Email_Address: body.Email_Address }),
...(body.Voxer_ID !== undefined && { Voxer_ID: body.Voxer_ID }),
...(body.Status !== undefined && { Status: body.Status })
});
return json(record);
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
await locals.pb.collection(COLLECTION).delete(params.id);
return new Response(null, { status: 204 });
};
+107
View File
@@ -0,0 +1,107 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%);
--radius: 0.5rem;
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 4.9% 83.9%);
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
/** Allow redirectTo only to same-origin paths (no open redirect). */
function safeRedirectTo(searchParams: URLSearchParams): string {
const to = searchParams.get('redirectTo');
if (!to || typeof to !== 'string') return '/';
if (!to.startsWith('/') || to.startsWith('//')) return '/';
return to;
}
export const load: PageServerLoad = ({ url, locals }) => {
if (locals.user) {
throw redirect(303, safeRedirectTo(url.searchParams));
}
};
export const actions: Actions = {
login: async ({ request, locals, url }) => {
const data = await request.formData();
const email = (data.get('email') as string)?.trim() ?? '';
const password = (data.get('password') as string) ?? '';
if (!email || !password) {
return fail(400, { error: 'Email and password are required.', email });
}
try {
await locals.pb.collection('users').authWithPassword(email, password);
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: string }).message)
: 'Invalid email or password.';
return fail(401, { error: message, email });
}
const redirectTo = safeRedirectTo(url.searchParams);
throw redirect(303, redirectTo);
}
};
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { formAction, data }: { formAction: any; data: ActionData } = $props();
</script>
<main class="flex min-h-screen flex-col items-center justify-center p-4">
<Card.Root class="w-full max-w-[400px]">
<Card.Header class="space-y-1 text-center">
<Card.Title class="text-2xl font-semibold">Login</Card.Title>
<Card.Description>
Enter your email and password to sign in.
</Card.Description>
</Card.Header>
<Card.Content>
<form
method="POST"
action="?/login"
use:enhance={() => {
return async ({ update }) => {
await update();
};
}}
class="space-y-4"
>
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="you@example.com"
autocomplete="email"
value={data?.email ?? ''}
required
class="w-full"
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label for="password">Password</Label>
<a
href="/login/forgot-password"
class="text-muted-foreground hover:text-primary text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
class="w-full"
/>
</div>
{#if data?.error}
<p class="text-destructive text-sm" role="alert">{data.error}</p>
{/if}
<Button type="submit" class="w-full">
Login
</Button>
</form>
</Card.Content>
</Card.Root>
</main>