Files
HRM/src/routes/[id]/+page.svelte
T
eewing 8139790202
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m4s
refactor(scope-options): update and standardize scope options across multiple files
- Reorganized and standardized scope options in `+page.svelte` and `+server.ts` files to improve clarity and consistency.
- Removed duplicate and inconsistent entries, ensuring a uniform list of scope options for better usability in the application.
2026-02-20 09:59:31 -06:00

873 lines
26 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 { Badge, badgeVariants } from '$lib/components/ui/badge/index.js';
import type {
EmployeeRecord,
EmployeeReportRecord,
ReportSentiment,
VoxerLink
} from './+page.server';
import * as Select from '$lib/components/ui/select/index.js';
import {
utcIsoToLocalDatetimeLocal,
nowToLocalDatetimeLocal,
localDatetimeLocalToUtcIso
} from '$lib/date-utils';
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[]>([]);
const SENTIMENT_OPTIONS: ReportSentiment[] = ['Positive', 'Negative', 'Neutral'];
let reportCreatedLocal = $state('');
let reportSentiment = $state<ReportSentiment>('Neutral');
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 | 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 })
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = (json as { error?: string }).error ?? 'Update failed';
const data = (json as { data?: unknown }).data;
patchError = data && typeof data === 'object' && data !== null
? `${msg}: ${JSON.stringify(data)}`
: msg;
return;
}
employee = (json.employee ?? employee) as EmployeeRecord;
} catch (e) {
patchError = e instanceof Error ? e.message : 'Update failed';
} finally {
patchSaving = false;
}
}
const SCOPE_OPTIONS = [
'cleanup',
'FRP',
'Framing',
'Framing Metal',
'Framing Wood',
'General',
'Grid',
'Hanging',
'Insulation',
'IT',
'L5',
'Masking',
'OFFICE',
'Paper',
'Sanding',
'Scrap',
'Taping',
'Tile',
'WT'
];
function employeeScopes(): string[] {
const raw = employee?.Scopes;
if (Array.isArray(raw)) return raw;
if (typeof raw === 'string') return raw ? [raw] : [];
return [];
}
function toggleScope(scope: string) {
if (!employee) return;
const current = employeeScopes();
const next = current.includes(scope)
? current.filter((s) => s !== scope)
: [...current, scope];
employee = { ...employee, Scopes: next };
patchField('Scopes', next);
}
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 = [];
reportCreatedLocal = nowToLocalDatetimeLocal();
reportSentiment = 'Neutral';
reportError = null;
editingReport = null;
openCreateReport = true;
}
function openEditReportDialog(report: EmployeeReportRecord) {
editingReport = report;
reportFormText = report.report ?? '';
reportVoxers = parseVoxers(report).map((v) => ({ ...v }));
reportCreatedLocal = utcIsoToLocalDatetimeLocal(report.created);
reportSentiment = (report.Sentiment === 'Positive' || report.Sentiment === 'Negative' ? report.Sentiment : 'Neutral') as ReportSentiment;
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,
created: localDatetimeLocalToUtcIso(reportCreatedLocal),
Sentiment: reportSentiment
})
});
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,
created: localDatetimeLocalToUtcIso(reportCreatedLocal),
Sentiment: reportSentiment
})
});
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"
onclick={(e) => {
if (typeof window !== 'undefined' && window.history.length > 1) {
e.preventDefault();
window.history.back();
}
}}
>
<ArrowLeftIcon class="size-4" />
</Button>
<h1 class="text-2xl font-semibold tracking-tight shrink-0 flex items-center gap-2 flex-wrap">
{employee?.First_Name} {employee?.Last_Name}
{#if employee?.Type}
<Badge variant="secondary">{employee.Type}</Badge>
{/if}
</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-type" class="text-muted-foreground text-sm">Type</Label>
{#if editMode}
<Select.Root
type="single"
value={employee.Type ?? ''}
onValueChange={(v) => {
employee = { ...employee!, Type: v ?? undefined };
if (v !== undefined) patchField('Type', v ?? '');
}}
disabled={patchSaving}
>
<Select.Trigger class="w-full" id="emp-type">
{employee.Type || 'Select type…'}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="(none)">(none)</Select.Item>
{#each ['Employee', 'Sub-Contractor', 'Former-Employee', 'Applicant', 'Blacklisted'] as opt}
<Select.Item value={opt} label={opt}>{opt}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{:else}
<dd>
{#if employee.Type}
<Badge variant="secondary">{employee.Type}</Badge>
{:else}
{/if}
</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>
<div class="flex flex-col gap-1">
<Label class="text-muted-foreground text-sm">Scopes</Label>
{#if editMode}
<div class="border-input bg-background flex min-h-[80px] flex-wrap gap-2 rounded-md border px-3 py-2">
{#each SCOPE_OPTIONS as scope}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
class="border-input size-4 rounded"
checked={employeeScopes().includes(scope)}
onchange={() => toggleScope(scope)}
disabled={patchSaving}
/>
{scope}
</label>
{/each}
</div>
{:else}
<dd>
{#if employeeScopes().length > 0}
<div class="flex flex-wrap gap-1">
{#each employeeScopes() as scope}
<Badge variant="secondary">{scope}</Badge>
{/each}
</div>
{:else}
{/if}
</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)}
{#if report.Sentiment}
· <span class="capitalize">{report.Sentiment}</span>
{/if}
</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="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:items-end">
<div class="space-y-2">
<Label for="new-created">Created (local time)</Label>
<Input
id="new-created"
type="datetime-local"
bind:value={reportCreatedLocal}
disabled={reportSaving}
/>
</div>
<div class="space-y-2">
<Label for="new-sentiment">Sentiment</Label>
<Select.Root bind:value={reportSentiment} disabled={reportSaving}>
<Select.Trigger class="w-full" id="new-sentiment">
{reportSentiment}
</Select.Trigger>
<Select.Content>
{#each SENTIMENT_OPTIONS as opt}
<Select.Item value={opt} label={opt}>{opt}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<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="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:items-end">
<div class="space-y-2">
<Label for="edit-created">Created (local time)</Label>
<Input
id="edit-created"
type="datetime-local"
bind:value={reportCreatedLocal}
disabled={reportSaving}
/>
</div>
<div class="space-y-2">
<Label for="edit-sentiment">Sentiment</Label>
<Select.Root bind:value={reportSentiment} disabled={reportSaving}>
<Select.Trigger class="w-full" id="edit-sentiment">
{reportSentiment}
</Select.Trigger>
<Select.Content>
{#each SENTIMENT_OPTIONS as opt}
<Select.Item value={opt} label={opt}>{opt}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<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>