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:
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user