From 09487bbedede0a436dd87efef17e758b92b9c21c Mon Sep 17 00:00:00 2001 From: eewing Date: Tue, 17 Feb 2026 09:13:10 -0600 Subject: [PATCH] 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. --- bun.lock | 5 + package.json | 1 + src/hooks.server.ts | 6 +- src/lib/components/ui/badge/badge.svelte | 46 ++ src/lib/components/ui/badge/index.ts | 15 + src/lib/realtime.ts | 77 ++ src/routes/+layout.svelte | 25 + src/routes/+page.svelte | 143 +++- src/routes/[id]/+page.server.ts | 61 ++ src/routes/[id]/+page.svelte | 674 ++++++++++++++++++ src/routes/api/employees/+server.ts | 40 +- src/routes/api/employees/[id]/+server.ts | 31 +- .../api/employees/[id]/reports/+server.ts | 44 ++ .../[id]/reports/[reportId]/+server.ts | 36 + 14 files changed, 1172 insertions(+), 32 deletions(-) create mode 100644 src/lib/components/ui/badge/badge.svelte create mode 100644 src/lib/components/ui/badge/index.ts create mode 100644 src/lib/realtime.ts create mode 100644 src/routes/[id]/+page.server.ts create mode 100644 src/routes/[id]/+page.svelte create mode 100644 src/routes/api/employees/[id]/reports/+server.ts create mode 100644 src/routes/api/employees/[id]/reports/[reportId]/+server.ts diff --git a/bun.lock b/bun.lock index 0aa4b0e..dad764c 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/package.json b/package.json index 05b7586..0f2d6d4 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dependencies": { "clsx": "^2.1.1", "pocketbase": "^0.26.8", + "svelte-sonner": "^1.0.7", "tailwind-merge": "^3.4.1" } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 318de97..eeff8b7 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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; }; diff --git a/src/lib/components/ui/badge/badge.svelte b/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..06d948a --- /dev/null +++ b/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,46 @@ + + + + + + {@render children?.()} + diff --git a/src/lib/components/ui/badge/index.ts b/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..b64eefa --- /dev/null +++ b/src/lib/components/ui/badge/index.ts @@ -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, +}; diff --git a/src/lib/realtime.ts b/src/lib/realtime.ts new file mode 100644 index 0000000..b9736c9 --- /dev/null +++ b/src/lib/realtime.ts @@ -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> = { + action: 'create' | 'update' | 'delete'; + record: T; +}; + +function getRecordLabel(record: Record, 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) { + 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> = []; + + try { + const u1 = await pb.collection(EMPLOYEE_COLLECTION).subscribe( + '*', + (e: RecordSubscription) => { + notify(EMPLOYEE_COLLECTION, e.action, e.record as Record); + } + ); + unsubs.push(u1); + const u2 = await pb.collection(REPORT_COLLECTION).subscribe( + '*', + (e: RecordSubscription) => { + notify(REPORT_COLLECTION, e.action, e.record as Record); + } + ); + unsubs.push(u2); + } catch { + // Realtime not available or auth failed + } + + return () => { + unsubs.forEach((u) => u()); + void pb.realtime.unsubscribe(); + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ef350c4..35c1c75 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,12 +1,37 @@ + + {@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f0f7482..2671d7c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,6 @@ @@ -189,6 +262,7 @@ type="search" placeholder="Search by name, email, phone, Voxer ID, status…" bind:value={searchQuery} + oninput={onSearchInput} class="pl-9" /> @@ -204,11 +278,11 @@ First Name Last Name + Status Phone Email Voxer ID - Status - Actions + @@ -226,24 +300,38 @@ {:else} {#each filteredEmployees as employee (employee.id)} - + goto(`/${employee.id}`)} + onkeydown={(e) => e.key === 'Enter' && goto(`/${employee.id}`)} + > {employee.First_Name} {employee.Last_Name} + {employee.Status} {employee.Phone_Number} {employee.Email_Address} {employee.Voxer_ID} - {employee.Status} - + e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > - + goto(`/${employee.id}`)}> + + View + openEditDialog(employee)}> Edit @@ -271,6 +359,17 @@ {/if} + {#if !loading && !error && hasMore} + + {/if} diff --git a/src/routes/[id]/+page.server.ts b/src/routes/[id]/+page.server.ts new file mode 100644 index 0000000..ef58869 --- /dev/null +++ b/src/routes/[id]/+page.server.ts @@ -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(id), + locals.pb + .collection(REPORTS_COLLECTION) + .getList(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; + } +}; diff --git a/src/routes/[id]/+page.svelte b/src/routes/[id]/+page.svelte new file mode 100644 index 0000000..1e20d5d --- /dev/null +++ b/src/routes/[id]/+page.svelte @@ -0,0 +1,674 @@ + + + + + {employee + ? `${employee.First_Name} ${employee.Last_Name} | HRM` + : 'Employee | HRM'} + + + +
+
+ +

+ {employee?.First_Name} {employee?.Last_Name} +

+ {#if employee} +
+ + +
+ {/if} +
+ + {#if employee} + {#if patchError} +

{patchError}

+ {/if} +
+
+
+ + {#if editMode} + (employee = { ...employee!, First_Name: (e.currentTarget as HTMLInputElement).value })} + onblur={() => patchField('First_Name', employee?.First_Name ?? '')} + disabled={patchSaving} + /> + {:else} +
{employee.First_Name || '—'}
+ {/if} +
+
+ + {#if editMode} + (employee = { ...employee!, Last_Name: (e.currentTarget as HTMLInputElement).value })} + onblur={() => patchField('Last_Name', employee?.Last_Name ?? '')} + disabled={patchSaving} + /> + {:else} +
{employee.Last_Name || '—'}
+ {/if} +
+
+ + {#if editMode} + (employee = { ...employee!, Status: (e.currentTarget as HTMLInputElement).value })} + onblur={() => patchField('Status', employee?.Status ?? '')} + disabled={patchSaving} + /> + {:else} +
{employee.Status || '—'}
+ {/if} +
+
+ + {#if editMode} + (employee = { ...employee!, Phone_Number: (e.currentTarget as HTMLInputElement).value })} + onblur={() => patchField('Phone_Number', employee?.Phone_Number ?? '')} + disabled={patchSaving} + /> + {:else} +
{employee.Phone_Number || '—'}
+ {/if} +
+
+ + {#if editMode} + (employee = { ...employee!, Email_Address: (e.currentTarget as HTMLInputElement).value })} + onblur={() => patchField('Email_Address', employee?.Email_Address ?? '')} + disabled={patchSaving} + /> + {:else} +
+ {#if employee.Email_Address} + {employee.Email_Address} + {:else} + — + {/if} +
+ {/if} +
+
+ + {#if editMode} + (employee = { ...employee!, Voxer_ID: (e.currentTarget as HTMLInputElement).value })} + onblur={() => patchField('Voxer_ID', employee?.Voxer_ID ?? '')} + disabled={patchSaving} + /> + {:else} +
{employee.Voxer_ID || '—'}
+ {/if} +
+
+ + + + + {#if editMode} +