Add svelte-sonner for notifications and enhance employee search functionality

- Integrated svelte-sonner for toast notifications in the layout.
- Implemented server-side search for employees with pagination in the API.
- Updated employee loading logic to support search queries and loading more results.
- Enhanced employee detail view with navigation and improved UI interactions.
- Adjusted cookie handling in hooks for PocketBase authentication.
This commit is contained in:
eewing
2026-02-17 09:13:10 -06:00
parent faae3c1a43
commit 09487bbede
14 changed files with 1172 additions and 32 deletions
+5
View File
@@ -7,6 +7,7 @@
"dependencies": {
"clsx": "^2.1.1",
"pocketbase": "^0.26.8",
"svelte-sonner": "^1.0.7",
"tailwind-merge": "^3.4.1",
},
"devDependencies": {
@@ -332,6 +333,8 @@
"svelte-check": ["svelte-check@4.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg=="],
"svelte-sonner": ["svelte-sonner@1.0.7", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A=="],
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
@@ -373,5 +376,7 @@
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
}
}
+1
View File
@@ -32,6 +32,7 @@
"dependencies": {
"clsx": "^2.1.1",
"pocketbase": "^0.26.8",
"svelte-sonner": "^1.0.7",
"tailwind-merge": "^3.4.1"
}
}
+5 -1
View File
@@ -49,7 +49,11 @@ export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
response.headers.append('set-cookie', event.locals.pb.authStore.exportToCookie());
// httpOnly: false so client PocketBase can read auth for realtime subscriptions
response.headers.append(
'set-cookie',
event.locals.pb.authStore.exportToCookie({ httpOnly: false, sameSite: 'Lax' })
);
return response;
};
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow-xs hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/80",
outline: "text-foreground border-input",
link: "border-transparent bg-primary/10 text-primary hover:bg-primary/20",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
export type BadgeProps = WithElementRef<HTMLAttributes<HTMLSpanElement>> & {
variant?: BadgeVariant;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
children,
...restProps
}: BadgeProps = $props();
</script>
<span
data-slot="badge"
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</span>
+15
View File
@@ -0,0 +1,15 @@
import Root, {
type BadgeProps,
type BadgeVariant,
badgeVariants,
} from "./badge.svelte";
export {
Root,
type BadgeProps as Props,
//
Root as Badge,
badgeVariants,
type BadgeProps,
type BadgeVariant,
};
+77
View File
@@ -0,0 +1,77 @@
import PocketBase from 'pocketbase';
import { toast } from 'svelte-sonner';
const POCKETBASE_URL = 'https://pocketbase.ccllc.pro';
const EMPLOYEE_COLLECTION = 'Employee_Records';
const REPORT_COLLECTION = 'Employee_Reports';
type RecordSubscription<T = Record<string, unknown>> = {
action: 'create' | 'update' | 'delete';
record: T;
};
function getRecordLabel(record: Record<string, unknown>, collection: string): string {
if (collection === EMPLOYEE_COLLECTION) {
const first = (record.First_Name as string) ?? '';
const last = (record.Last_Name as string) ?? '';
if (first || last) return `${first} ${last}`.trim();
}
if (collection === REPORT_COLLECTION) {
const report = (record.report as string) ?? '';
if (report) return report.slice(0, 50) + (report.length > 50 ? '…' : '');
}
return 'Record';
}
function notify(collection: string, action: string, record: Record<string, unknown>) {
const label = getRecordLabel(record, collection);
const collectionLabel =
collection === EMPLOYEE_COLLECTION ? 'Employee' : 'Report';
const title =
action === 'create'
? `${collectionLabel} added`
: action === 'update'
? `${collectionLabel} updated`
: `${collectionLabel} removed`;
const description = action === 'delete' ? undefined : label;
toast(title, {
description: description || undefined,
...(action === 'create' && { duration: 4000 })
});
}
export async function initRealtime(): Promise<() => void> {
const pb = new PocketBase(POCKETBASE_URL);
pb.authStore.loadFromCookie(document.cookie);
if (!pb.authStore.isValid) {
return () => {};
}
const unsubs: Array<() => Promise<void>> = [];
try {
const u1 = await pb.collection(EMPLOYEE_COLLECTION).subscribe(
'*',
(e: RecordSubscription) => {
notify(EMPLOYEE_COLLECTION, e.action, e.record as Record<string, unknown>);
}
);
unsubs.push(u1);
const u2 = await pb.collection(REPORT_COLLECTION).subscribe(
'*',
(e: RecordSubscription) => {
notify(REPORT_COLLECTION, e.action, e.record as Record<string, unknown>);
}
);
unsubs.push(u2);
} catch {
// Realtime not available or auth failed
}
return () => {
unsubs.forEach((u) => u());
void pb.realtime.unsubscribe();
};
}
+25
View File
@@ -1,12 +1,37 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Toaster } from 'svelte-sonner';
import './app.css';
import favicon from '$lib/assets/favicon.svg';
import { initRealtime } from '$lib/realtime';
let { children } = $props();
let cleanupRealtime: (() => void) | undefined;
onMount(() => {
let cancelled = false;
initRealtime().then((cleanup) => {
if (!cancelled) cleanupRealtime = cleanup;
});
return () => {
cancelled = true;
cleanupRealtime?.();
};
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<Toaster
position="bottom-right"
richColors
closeButton
toastOptions={{
unstyled: false,
class: 'border border-border bg-card text-card-foreground shadow-lg'
}}
/>
{@render children()}
+121 -22
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
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';
@@ -10,13 +11,19 @@
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import PencilIcon from '@lucide/svelte/icons/pencil';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import EyeIcon from '@lucide/svelte/icons/eye';
import PlusIcon from '@lucide/svelte/icons/plus';
import SearchIcon from '@lucide/svelte/icons/search';
let employees = $state<EmployeeRecord[]>([]);
let total = $state(0);
let loading = $state(true);
let loadingMore = $state(false);
let error = $state<string | null>(null);
let searchQuery = $state('');
let nextPage = $state(2);
let searchMode = $state(false); // true when showing server-side search results
let searchDebounceId: ReturnType<typeof setTimeout> | null = null;
let openCreate = $state(false);
let openEdit = $state(false);
@@ -36,11 +43,14 @@
let deleteError = $state<string | null>(null);
async function loadEmployees() {
searchMode = false;
try {
const res = await fetch('/api/employees');
const res = await fetch('/api/employees?page=1&perPage=200');
if (!res.ok) throw new Error(res.statusText);
const json = await res.json();
employees = json.employees ?? [];
total = json.total ?? 0;
nextPage = 2;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load employees';
@@ -49,6 +59,64 @@
}
}
async function searchEmployees(query: string) {
if (!query.trim()) {
await loadEmployees();
return;
}
searchMode = true;
loading = true;
error = null;
try {
const res = await fetch(
`/api/employees?page=1&perPage=500&search=${encodeURIComponent(query.trim())}`
);
if (!res.ok) throw new Error(res.statusText);
const json = await res.json();
employees = json.employees ?? [];
total = json.total ?? 0;
nextPage = 2;
} catch (e) {
error = e instanceof Error ? e.message : 'Search failed';
employees = [];
} finally {
loading = false;
}
}
function onSearchInput() {
if (searchDebounceId) clearTimeout(searchDebounceId);
const q = searchQuery.trim();
if (!q) {
searchDebounceId = null;
// Clear search: reload first page of full list
loading = true;
loadEmployees();
return;
}
searchDebounceId = setTimeout(() => {
searchDebounceId = null;
searchEmployees(searchQuery);
}, 300);
}
async function loadMore() {
if (loadingMore || employees.length >= total) return;
loadingMore = true;
try {
const res = await fetch(`/api/employees?page=${nextPage}&perPage=100`);
if (!res.ok) throw new Error(res.statusText);
const json = await res.json();
const list = json.employees ?? [];
employees = [...employees, ...list];
nextPage += 1;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load more';
} finally {
loadingMore = false;
}
}
function resetForm() {
formFirst = '';
formLast = '';
@@ -160,21 +228,26 @@
}
}
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)
);
})
// When using server-side search, employees are already filtered; otherwise show loaded list
const filteredEmployees = $derived(employees);
const hasMore = $derived(
!searchMode && employees.length < total && !loading
);
function intersectionObserverAction(node: HTMLDivElement, onVisible: () => void) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) onVisible();
},
{ rootMargin: '200px', threshold: 0 }
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};
}
onMount(loadEmployees);
</script>
@@ -189,6 +262,7 @@
type="search"
placeholder="Search by name, email, phone, Voxer ID, status…"
bind:value={searchQuery}
oninput={onSearchInput}
class="pl-9"
/>
</div>
@@ -204,11 +278,11 @@
<Table.Row>
<Table.Head>First Name</Table.Head>
<Table.Head>Last Name</Table.Head>
<Table.Head>Status</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.Head class="w-[70px]"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -226,24 +300,38 @@
</Table.Row>
{:else}
{#each filteredEmployees as employee (employee.id)}
<Table.Row>
<Table.Row
role="button"
tabindex="0"
class="cursor-pointer hover:bg-muted/50 focus-visible:outline focus-visible:ring-2 focus-visible:ring-ring"
onclick={() => goto(`/${employee.id}`)}
onkeydown={(e) => e.key === 'Enter' && goto(`/${employee.id}`)}
>
<Table.Cell>{employee.First_Name}</Table.Cell>
<Table.Cell>{employee.Last_Name}</Table.Cell>
<Table.Cell>{employee.Status}</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>
<Table.Cell
class="cursor-default"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<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 variant="ghost" size="icon" class="size-8" type="button">
<EllipsisVerticalIcon class="size-4" aria-label="Actions" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => goto(`/${employee.id}`)}>
<EyeIcon class="size-4" />
View
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openEditDialog(employee)}>
<PencilIcon class="size-4" />
Edit
@@ -271,6 +359,17 @@
{/if}
</Table.Body>
</Table.Root>
{#if !loading && !error && hasMore}
<div
use:intersectionObserverAction={loadMore}
class="flex min-h-12 items-center justify-center py-4"
aria-hidden="true"
>
{#if loadingMore}
<span class="text-muted-foreground text-sm">Loading more…</span>
{/if}
</div>
{/if}
</div>
</main>
+61
View File
@@ -0,0 +1,61 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const EMPLOYEES_COLLECTION = 'Employee_Records';
const REPORTS_COLLECTION = 'Employee_Reports';
export interface EmployeeRecord {
id: string;
First_Name: string;
Last_Name: string;
Phone_Number: string;
Email_Address: string;
Voxer_ID: string;
Status: string;
Notes?: string;
[key: string]: unknown;
}
export interface VoxerLink {
label: string;
url: string;
}
export interface EmployeeReportRecord {
id: string;
report?: string;
voxers?: VoxerLink[] | string; // JSON array or string from PB
created: string;
updated: string;
[key: string]: unknown;
}
export const load: PageServerLoad = async ({ params, locals }) => {
const id = params.id;
try {
const [employee, reportsResult] = await Promise.all([
locals.pb.collection(EMPLOYEES_COLLECTION).getOne<EmployeeRecord>(id),
locals.pb
.collection(REPORTS_COLLECTION)
.getList<EmployeeReportRecord>(1, 100, {
filter: `Employee = "${id}" && delete != true`,
sort: '-created'
})
]);
if ((employee as { delete?: boolean }).delete === true) {
throw error(404, 'Employee not found');
}
return {
employee,
reports: reportsResult.items ?? []
};
} catch (e: unknown) {
const is404 =
e &&
typeof e === 'object' &&
'status' in e &&
(e as { status: number }).status === 404;
if (is404) throw error(404, 'Employee not found');
throw e;
}
};
+674
View File
@@ -0,0 +1,674 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
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 * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { badgeVariants } from '$lib/components/ui/badge/index.js';
import type {
EmployeeRecord,
EmployeeReportRecord,
VoxerLink
} from './+page.server';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import LinkIcon from '@lucide/svelte/icons/link';
import PencilIcon from '@lucide/svelte/icons/pencil';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import PlusIcon from '@lucide/svelte/icons/plus';
let { data } = $props();
const employeeId = $derived($page.params.id);
function formatDate(val: string | undefined): string {
if (!val) return '—';
try {
return new Date(val).toLocaleString(undefined, {
dateStyle: 'short',
timeStyle: 'short'
});
} catch {
return val;
}
}
let employee = $state<EmployeeRecord | null>(data?.employee ?? null);
let reports = $state<EmployeeReportRecord[]>((data?.reports ?? []) as EmployeeReportRecord[]);
let editMode = $state(false);
let patchSaving = $state(false);
let patchError = $state<string | null>(null);
let openDelete = $state(false);
let deleteSaving = $state(false);
let deleteError = $state<string | null>(null);
// Report CRUD state
let openCreateReport = $state(false);
let openEditReport = $state(false);
let openDeleteReport = $state(false);
let reportFormText = $state('');
let reportVoxers = $state<VoxerLink[]>([]);
let editingReport = $state<EmployeeReportRecord | null>(null);
let deletingReport = $state<EmployeeReportRecord | null>(null);
let reportSaving = $state(false);
let reportError = $state<string | null>(null);
let reportDeleteSaving = $state(false);
let reportDeleteError = $state<string | null>(null);
function parseVoxers(report: EmployeeReportRecord): VoxerLink[] {
const raw = report.voxers;
if (!raw) return [];
if (Array.isArray(raw)) return raw as VoxerLink[];
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? (parsed as VoxerLink[]) : [];
} catch {
return [];
}
}
return [];
}
function nextVoxLabel(voxers: VoxerLink[]): string {
const n = voxers.length + 1;
return `Vox ${n}`;
}
$effect(() => {
if (data?.employee) employee = data.employee as EmployeeRecord;
if (data?.reports) reports = (data.reports as EmployeeReportRecord[]).slice();
});
async function loadReports() {
try {
const res = await fetch(`/api/employees/${employeeId}/reports`);
if (!res.ok) throw new Error('Failed to load reports');
const json = await res.json();
reports = json.reports ?? [];
} catch {
reports = [];
}
}
async function loadEmployee() {
try {
const res = await fetch(`/api/employees/${employeeId}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: string }).error ?? 'Failed to load');
}
const json = await res.json();
employee = (json.employee ?? null) as EmployeeRecord | null;
} catch (e) {
employee = null;
}
}
async function patchField(field: keyof EmployeeRecord, value: string) {
if (!employee?.id) return;
patchSaving = true;
patchError = null;
try {
const res = await fetch(`/api/employees/${employeeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value })
});
if (!res.ok) throw new Error('Update failed');
const json = await res.json();
employee = (json.employee ?? employee) as EmployeeRecord;
} catch (e) {
patchError = e instanceof Error ? e.message : 'Update failed';
} finally {
patchSaving = false;
}
}
async function confirmDelete() {
deleteSaving = true;
deleteError = null;
try {
const res = await fetch(`/api/employees/${employeeId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(res.statusText);
openDelete = false;
goto('/');
} catch (e) {
deleteError = e instanceof Error ? e.message : 'Failed to delete';
} finally {
deleteSaving = false;
}
}
function openCreateReportDialog() {
reportFormText = '';
reportVoxers = [];
reportError = null;
editingReport = null;
openCreateReport = true;
}
function openEditReportDialog(report: EmployeeReportRecord) {
editingReport = report;
reportFormText = report.report ?? '';
reportVoxers = parseVoxers(report).map((v) => ({ ...v }));
reportError = null;
openEditReport = true;
}
function addVoxer() {
reportVoxers = [...reportVoxers, { label: nextVoxLabel(reportVoxers), url: '' }];
}
function removeVoxer(i: number) {
reportVoxers = reportVoxers.filter((_, idx) => idx !== i);
}
function openDeleteReportDialog(report: EmployeeReportRecord) {
deletingReport = report;
reportDeleteError = null;
openDeleteReport = true;
}
async function submitCreateReport() {
reportSaving = true;
reportError = null;
try {
const res = await fetch(`/api/employees/${employeeId}/reports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ report: reportFormText, voxers: reportVoxers })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: string }).error ?? 'Failed to create');
}
openCreateReport = false;
await loadReports();
} catch (e) {
reportError = e instanceof Error ? e.message : 'Failed to create report';
} finally {
reportSaving = false;
}
}
async function submitEditReport() {
if (!editingReport) return;
reportSaving = true;
reportError = null;
try {
const res = await fetch(`/api/employees/${employeeId}/reports/${editingReport.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ report: reportFormText, voxers: reportVoxers })
});
if (!res.ok) throw new Error('Update failed');
openEditReport = false;
editingReport = null;
await loadReports();
} catch (e) {
reportError = e instanceof Error ? e.message : 'Failed to update report';
} finally {
reportSaving = false;
}
}
async function confirmDeleteReport() {
if (!deletingReport) return;
reportDeleteSaving = true;
reportDeleteError = null;
try {
const res = await fetch(`/api/employees/${employeeId}/reports/${deletingReport.id}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error(res.statusText);
openDeleteReport = false;
deletingReport = null;
await loadReports();
} catch (e) {
reportDeleteError = e instanceof Error ? e.message : 'Failed to delete';
} finally {
reportDeleteSaving = false;
}
}
</script>
<svelte:head>
<title>
{employee
? `${employee.First_Name} ${employee.Last_Name} | HRM`
: 'Employee | HRM'}
</title>
</svelte:head>
<main class="flex flex-col gap-6 p-6 md:p-8">
<div class="flex flex-wrap items-center gap-4">
<Button variant="ghost" size="icon" href="/" class="shrink-0">
<ArrowLeftIcon class="size-4" />
</Button>
<h1 class="text-2xl font-semibold tracking-tight shrink-0">
{employee?.First_Name} {employee?.Last_Name}
</h1>
{#if employee}
<div class="flex items-center gap-2 ml-auto">
<Button
variant={editMode ? 'secondary' : 'outline'}
size="sm"
onclick={() => (editMode = !editMode)}
>
<PencilIcon class="size-4" />
{editMode ? 'Done' : 'Edit'}
</Button>
<Button variant="outline" size="sm" onclick={() => (openDelete = true)}>
<Trash2Icon class="size-4" />
Delete
</Button>
</div>
{/if}
</div>
{#if employee}
{#if patchError}
<p class="text-destructive text-sm">{patchError}</p>
{/if}
<div class="grid gap-6 md:grid-cols-2 md:gap-8">
<dl class="grid gap-4 sm:grid-cols-2 content-start">
<div class="flex flex-col gap-1">
<Label for="emp-first" class="text-muted-foreground text-sm">First name</Label>
{#if editMode}
<Input
id="emp-first"
value={employee.First_Name ?? ''}
oninput={(e) => (employee = { ...employee!, First_Name: (e.currentTarget as HTMLInputElement).value })}
onblur={() => patchField('First_Name', employee?.First_Name ?? '')}
disabled={patchSaving}
/>
{:else}
<dd>{employee.First_Name || '—'}</dd>
{/if}
</div>
<div class="flex flex-col gap-1">
<Label for="emp-last" class="text-muted-foreground text-sm">Last name</Label>
{#if editMode}
<Input
id="emp-last"
value={employee.Last_Name ?? ''}
oninput={(e) => (employee = { ...employee!, Last_Name: (e.currentTarget as HTMLInputElement).value })}
onblur={() => patchField('Last_Name', employee?.Last_Name ?? '')}
disabled={patchSaving}
/>
{:else}
<dd>{employee.Last_Name || '—'}</dd>
{/if}
</div>
<div class="flex flex-col gap-1">
<Label for="emp-status" class="text-muted-foreground text-sm">Status</Label>
{#if editMode}
<Input
id="emp-status"
value={employee.Status ?? ''}
oninput={(e) => (employee = { ...employee!, Status: (e.currentTarget as HTMLInputElement).value })}
onblur={() => patchField('Status', employee?.Status ?? '')}
disabled={patchSaving}
/>
{:else}
<dd>{employee.Status || '—'}</dd>
{/if}
</div>
<div class="flex flex-col gap-1">
<Label for="emp-phone" class="text-muted-foreground text-sm">Phone</Label>
{#if editMode}
<Input
id="emp-phone"
value={employee.Phone_Number ?? ''}
oninput={(e) => (employee = { ...employee!, Phone_Number: (e.currentTarget as HTMLInputElement).value })}
onblur={() => patchField('Phone_Number', employee?.Phone_Number ?? '')}
disabled={patchSaving}
/>
{:else}
<dd>{employee.Phone_Number || '—'}</dd>
{/if}
</div>
<div class="flex flex-col gap-1">
<Label for="emp-email" class="text-muted-foreground text-sm">Email</Label>
{#if editMode}
<Input
id="emp-email"
type="email"
value={employee.Email_Address ?? ''}
oninput={(e) => (employee = { ...employee!, Email_Address: (e.currentTarget as HTMLInputElement).value })}
onblur={() => patchField('Email_Address', employee?.Email_Address ?? '')}
disabled={patchSaving}
/>
{:else}
<dd>
{#if employee.Email_Address}
<a href={`mailto:${employee.Email_Address}`} class="text-primary underline underline-offset-4">{employee.Email_Address}</a>
{:else}
{/if}
</dd>
{/if}
</div>
<div class="flex flex-col gap-1">
<Label for="emp-voxer" class="text-muted-foreground text-sm">Voxer ID</Label>
{#if editMode}
<Input
id="emp-voxer"
value={employee.Voxer_ID ?? ''}
oninput={(e) => (employee = { ...employee!, Voxer_ID: (e.currentTarget as HTMLInputElement).value })}
onblur={() => patchField('Voxer_ID', employee?.Voxer_ID ?? '')}
disabled={patchSaving}
/>
{:else}
<dd>{employee.Voxer_ID || '—'}</dd>
{/if}
</div>
</dl>
<!-- Notes section -->
<Card.Root class="h-fit">
<Card.Content>
{#if editMode}
<textarea
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground min-h-[140px] w-full rounded-md border px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
placeholder="Rich text notes…"
value={employee?.Notes ?? ''}
oninput={(e) =>
(employee = { ...employee!, Notes: (e.currentTarget as HTMLTextAreaElement).value })}
onblur={() => patchField('Notes', employee?.Notes ?? '')}
disabled={patchSaving}
rows="6"
/>
{:else}
{#if employee.Notes}
<div
class="prose prose-sm dark:prose-invert max-w-none text-foreground [&_a]:text-primary [&_a]:underline [&_ul]:list-disc [&_ol]:list-decimal"
>
{@html employee.Notes}
</div>
{:else}
<p class="text-muted-foreground text-sm">No notes.</p>
{/if}
{/if}
</Card.Content>
</Card.Root>
</div>
{#if patchSaving}
<p class="text-muted-foreground text-sm">Saving…</p>
{/if}
<!-- Employee reports -->
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<h2 class="text-lg font-semibold tracking-tight">Reports</h2>
<Button size="sm" onclick={openCreateReportDialog}>
<PlusIcon class="size-4" />
Add report
</Button>
</div>
{#if reports.length > 0}
<div class="flex flex-col gap-4">
{#each reports as report (report.id)}
<Card.Root class="flex flex-col">
<Card.Header class="flex flex-row items-start justify-between gap-2 space-y-0 pb-2">
<div class="flex flex-col gap-0.5">
<Card.Title class="text-base">Report</Card.Title>
<p class="text-muted-foreground text-xs">
Created {formatDate(report.created)} · Updated {formatDate(report.updated)}
</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
variant="ghost"
size="icon"
class="size-8"
onclick={() => openEditReportDialog(report)}
aria-label="Edit report"
>
<PencilIcon class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8 text-destructive hover:text-destructive"
onclick={() => openDeleteReportDialog(report)}
aria-label="Delete report"
>
<Trash2Icon class="size-4" />
</Button>
</div>
</Card.Header>
<Card.Content class="flex-1 pt-0 space-y-3">
<p class="text-sm whitespace-pre-wrap">{report.report || '—'}</p>
{@const voxersList = parseVoxers(report)}
{#if voxersList.length > 0}
<div class="flex flex-wrap gap-2">
{#each voxersList as vox (vox.url + vox.label)}
<a
href={vox.url}
target="_blank"
rel="noopener noreferrer"
class={badgeVariants({ variant: 'link' }) + ' cursor-pointer no-underline'}
>
<LinkIcon class="mr-1 inline size-3" />
{vox.label || 'Link'}
</a>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
{/each}
</div>
{:else}
<p class="text-muted-foreground text-sm">No reports yet. Add one to get started.</p>
{/if}
</div>
{:else}
<p class="text-muted-foreground">Employee not found.</p>
<Button variant="outline" onclick={loadEmployee}>Retry</Button>
{/if}
</main>
<!-- Delete confirmation -->
<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 employee}
<strong>{employee.First_Name} {employee.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>
<!-- Create report -->
<Dialog.Root bind:open={openCreateReport}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Add report</Dialog.Title>
<Dialog.Description>Add a new report for this employee.</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
submitCreateReport();
}}
class="grid gap-4 py-4"
>
<div class="space-y-2">
<Label for="new-report">Report</Label>
<textarea
id="new-report"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground flex min-h-[120px] w-full rounded-md border px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter report content…"
bind:value={reportFormText}
disabled={reportSaving}
rows="5"
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Voxer links</Label>
<Button type="button" variant="outline" size="sm" onclick={addVoxer} disabled={reportSaving}>
<PlusIcon class="size-4" />
Add link
</Button>
</div>
{#each reportVoxers as vox, i}
<div class="flex gap-2">
<Input
placeholder="Label (e.g. Vox 1)"
value={vox.label}
oninput={(e) => {
reportVoxers = reportVoxers.map((v, idx) =>
idx === i ? { ...v, label: (e.currentTarget as HTMLInputElement).value } : v
);
}}
class="flex-1"
/>
<Input
placeholder="https://…"
value={vox.url}
oninput={(e) => {
reportVoxers = reportVoxers.map((v, idx) =>
idx === i ? { ...v, url: (e.currentTarget as HTMLInputElement).value } : v
);
}}
class="flex-[2]"
/>
<Button type="button" variant="ghost" size="icon" onclick={() => removeVoxer(i)} aria-label="Remove link">
<Trash2Icon class="size-4" />
</Button>
</div>
{/each}
</div>
{#if reportError}
<p class="text-destructive text-sm">{reportError}</p>
{/if}
<Dialog.Footer>
<Dialog.Close asChild>
<Button type="button" variant="outline">Cancel</Button>
</Dialog.Close>
<Button type="submit" disabled={reportSaving}>
{reportSaving ? 'Saving…' : 'Add report'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Edit report -->
<Dialog.Root bind:open={openEditReport}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit report</Dialog.Title>
<Dialog.Description>Update this report.</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
submitEditReport();
}}
class="grid gap-4 py-4"
>
<div class="space-y-2">
<Label for="edit-report">Report</Label>
<textarea
id="edit-report"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground flex min-h-[120px] w-full rounded-md border px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter report content…"
bind:value={reportFormText}
disabled={reportSaving}
rows="5"
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Voxer links</Label>
<Button type="button" variant="outline" size="sm" onclick={addVoxer} disabled={reportSaving}>
<PlusIcon class="size-4" />
Add link
</Button>
</div>
{#each reportVoxers as vox, i}
<div class="flex gap-2">
<Input
placeholder="Label (e.g. Vox 1)"
value={vox.label}
oninput={(e) => {
reportVoxers = reportVoxers.map((v, idx) =>
idx === i ? { ...v, label: (e.currentTarget as HTMLInputElement).value } : v
);
}}
class="flex-1"
/>
<Input
placeholder="https://…"
value={vox.url}
oninput={(e) => {
reportVoxers = reportVoxers.map((v, idx) =>
idx === i ? { ...v, url: (e.currentTarget as HTMLInputElement).value } : v
);
}}
class="flex-[2]"
/>
<Button type="button" variant="ghost" size="icon" onclick={() => removeVoxer(i)} aria-label="Remove link">
<Trash2Icon class="size-4" />
</Button>
</div>
{/each}
</div>
{#if reportError}
<p class="text-destructive text-sm">{reportError}</p>
{/if}
<Dialog.Footer>
<Dialog.Close asChild>
<Button type="button" variant="outline">Cancel</Button>
</Dialog.Close>
<Button type="submit" disabled={reportSaving}>
{reportSaving ? 'Saving…' : 'Save'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Delete report confirmation -->
<Dialog.Root bind:open={openDeleteReport}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete report</Dialog.Title>
<Dialog.Description>
Are you sure you want to delete this report? This cannot be undone.
</Dialog.Description>
</Dialog.Header>
{#if reportDeleteError}
<p class="text-destructive text-sm">{reportDeleteError}</p>
{/if}
<Dialog.Footer>
<Dialog.Close asChild>
<Button type="button" variant="outline">Cancel</Button>
</Dialog.Close>
<Button variant="destructive" disabled={reportDeleteSaving} onclick={confirmDeleteReport}>
{reportDeleteSaving ? 'Deleting…' : 'Delete'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+34 -6
View File
@@ -14,12 +14,38 @@ export interface EmployeeRecord {
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' });
/** Escape a string for use inside PocketBase filter double-quotes. */
function escapeFilterValue(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
return json({ employees: items });
export const GET: RequestHandler = async ({ locals, url }) => {
const search = url.searchParams.get('search')?.trim() ?? '';
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
const perPage = Math.min(500, Math.max(1, parseInt(url.searchParams.get('perPage') ?? '200', 10)));
let filter = 'delete != true';
if (search.length > 0) {
const escaped = escapeFilterValue(search);
// Search across main text fields (PocketBase ~ is "contains")
filter = `(${[
`First_Name ~ "${escaped}"`,
`Last_Name ~ "${escaped}"`,
`Email_Address ~ "${escaped}"`,
`Phone_Number ~ "${escaped}"`,
`Voxer_ID ~ "${escaped}"`,
`Status ~ "${escaped}"`
].join(' || ')}) && delete != true`;
}
const { items, totalItems } = await locals.pb
.collection(COLLECTION)
.getList<EmployeeRecord>(page, perPage, {
filter,
sort: 'Last_Name,First_Name'
});
return json({ employees: items, total: totalItems });
};
export const POST: RequestHandler = async ({ request, locals }) => {
@@ -30,7 +56,9 @@ export const POST: RequestHandler = async ({ request, locals }) => {
Phone_Number: body.Phone_Number ?? '',
Email_Address: body.Email_Address ?? '',
Voxer_ID: body.Voxer_ID ?? '',
Status: body.Status ?? ''
Status: body.Status ?? '',
delete: false,
...(locals.user?.id && { entered_by: locals.user.id })
});
return json(record);
};
+28 -3
View File
@@ -4,6 +4,26 @@ import type { EmployeeRecord } from '../+server';
const COLLECTION = 'Employee_Records';
export const GET: RequestHandler = async ({ params, locals }) => {
const id = params.id;
try {
const employee = await locals.pb
.collection(COLLECTION)
.getOne<EmployeeRecord>(id);
if ((employee as { delete?: boolean }).delete === true) {
return json({ error: 'Employee not found' }, { status: 404 });
}
return json({ employee });
} catch (e: unknown) {
const is404 =
e &&
typeof e === 'object' &&
'status' in e &&
(e as { status: number }).status === 404;
return json({ error: 'Employee not found' }, { status: is404 ? 404 : 500 });
}
};
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const id = params.id;
const body = await request.json();
@@ -13,12 +33,17 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
...(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 })
...(body.Status !== undefined && { Status: body.Status }),
...(body.Notes !== undefined && { Notes: body.Notes }),
...(locals.user?.id && { updated_by: locals.user.id })
});
return json(record);
return json({ employee: record });
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
await locals.pb.collection(COLLECTION).delete(params.id);
await locals.pb.collection(COLLECTION).update(params.id, {
delete: true,
...(locals.user?.id && { updated_by: locals.user.id })
});
return new Response(null, { status: 204 });
};
@@ -0,0 +1,44 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const REPORTS_COLLECTION = 'Employee_Reports';
export interface EmployeeReportRecord {
id: string;
report?: string;
created: string;
updated: string;
[key: string]: unknown;
}
export const GET: RequestHandler = async ({ params, locals }) => {
const employeeId = params.id;
const { items } = await locals.pb
.collection(REPORTS_COLLECTION)
.getList<EmployeeReportRecord>(1, 100, {
filter: `Employee = "${employeeId}" && delete != true`,
sort: '-created'
});
return json({ reports: items });
};
export const POST: RequestHandler = async ({ params, request, locals }) => {
const employeeId = params.id;
const body = await request.json();
let voxers: Array<{ label: string; url: string }> = [];
if (body.voxers != null) {
try {
voxers = Array.isArray(body.voxers) ? body.voxers : JSON.parse(String(body.voxers));
} catch {
voxers = [];
}
}
const report = await locals.pb.collection(REPORTS_COLLECTION).create<EmployeeReportRecord>({
Employee: employeeId,
report: body.report ?? '',
voxers,
delete: false,
...(locals.user?.id && { entered_by: locals.user.id })
});
return json({ report });
};
@@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const REPORTS_COLLECTION = 'Employee_Reports';
export interface EmployeeReportRecord {
id: string;
report?: string;
created: string;
updated: string;
[key: string]: unknown;
}
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const { reportId } = params;
const body = await request.json();
const update: Record<string, unknown> = {
...(body.report !== undefined && { report: body.report }),
...(locals.user?.id && { updated_by: locals.user.id })
};
if (body.voxers !== undefined) {
update.voxers = Array.isArray(body.voxers) ? body.voxers : JSON.parse(String(body.voxers));
}
const report = await locals.pb
.collection(REPORTS_COLLECTION)
.update<EmployeeReportRecord>(reportId, update);
return json({ report });
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
await locals.pb.collection(REPORTS_COLLECTION).update(params.reportId, {
delete: true,
...(locals.user?.id && { updated_by: locals.user.id })
});
return new Response(null, { status: 204 });
};