Files
HRM/src/routes/[id]/+page.svelte
T
eewing 09487bbede 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.
2026-02-17 09:13:10 -06:00

675 lines
21 KiB
Svelte

<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>