a5d3ac3a84
- Introduced a new `Scopes` field in the `EmployeeRecord` interface to categorize employee scopes. - Implemented scope filtering in the employee listing, allowing users to filter by various scopes. - Enhanced employee detail and edit views to manage employee scopes with checkboxes. - Updated API endpoints to support scope data during employee creation and updates. - Adjusted UI components to display employee scopes in lists and detail views.
865 lines
26 KiB
Svelte
865 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 = [
|
|
'General',
|
|
'OFFICE',
|
|
'IT',
|
|
'Framing',
|
|
'Insulation',
|
|
'Hanging',
|
|
'Scarp & Paper',
|
|
'Taping',
|
|
'Sand & Mask',
|
|
'WT & Cleanup',
|
|
'Grid & Tile'
|
|
];
|
|
|
|
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>
|