Files
Crussell/frontend/src/lib/components/admin/PatchTestsManagement.svelte
T

406 lines
11 KiB
Svelte

<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Modal from '$lib/components/ui/dialog';
type Service = {
id: string;
name: string;
};
type PatchTest = {
id: string;
name: string;
description: string | null;
notice_duration_hours: number;
expiry_months: number;
service_ids: string[];
};
let patchTests = $state<PatchTest[]>([]);
let availableServices = $state<Service[]>([]);
let loading = $state(true);
let isSubmitting = $state(false);
let showModal = $state(false);
let isEditing = $state(false);
let formData = $state({
id: '',
name: '',
description: '',
notice_duration_hours: 48,
expiry_months: 6,
service_ids: [] as string[]
});
async function fetchData() {
loading = true;
try {
const [ptRes, svcRes] = await Promise.all([
fetch('/api/admin/patch-tests', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch('/api/admin/services', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
})
]);
if (ptRes.ok && svcRes.ok) {
patchTests = await ptRes.json();
const svcData = await svcRes.json();
const uniqueSvc = new Map();
svcData.forEach((s: Service) => {
if (s.id) uniqueSvc.set(s.id, s);
});
availableServices = Array.from(uniqueSvc.values());
} else {
toast.error('Failed to load patch test data');
}
} catch (err) {
console.error(err);
toast.error('Network error loading data');
} finally {
loading = false;
}
}
function resetForm() {
formData = {
id: '',
name: '',
description: '',
notice_duration_hours: 48,
expiry_months: 6,
service_ids: []
};
isEditing = false;
}
function openAddModal() {
resetForm();
showModal = true;
}
function openEditModal(pt: PatchTest) {
formData = {
id: pt.id,
name: pt.name,
description: pt.description || '',
notice_duration_hours: pt.notice_duration_hours,
expiry_months: pt.expiry_months,
service_ids: pt.service_ids || []
};
isEditing = true;
showModal = true;
}
function toggleServiceSelection(serviceId: string) {
if (formData.service_ids.includes(serviceId)) {
formData.service_ids = formData.service_ids.filter((id) => id !== serviceId);
} else {
formData.service_ids = [...formData.service_ids, serviceId];
}
}
async function savePatchTest() {
if (!formData.name.trim()) {
toast.error('Patch test name is required');
return;
}
isSubmitting = true;
const method = isEditing ? 'PUT' : 'POST';
const url = isEditing ? `/api/admin/patch-tests/${formData.id}` : '/api/admin/patch-tests';
const payload = {
name: formData.name.trim(),
description: formData.description.trim() || undefined,
notice_duration_hours: Number(formData.notice_duration_hours),
expiry_months: Number(formData.expiry_months),
service_ids: formData.service_ids
};
try {
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (res.ok) {
toast.success(`Patch test ${isEditing ? 'updated' : 'created'} successfully!`);
showModal = false;
await fetchData();
} else {
const errText = await res.text();
toast.error(`Failed to save: ${errText}`);
}
} catch (err) {
console.error(err);
toast.error('Network error saving patch test');
} finally {
isSubmitting = false;
}
}
async function deletePatchTest(id: string) {
if (!confirm('Are you sure you want to delete this patch test requirement?')) return;
try {
const res = await fetch(`/api/admin/patch-tests/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
toast.success('Patch test deleted');
await fetchData();
} else {
toast.error('Failed to delete patch test');
}
} catch (err) {
console.error(err);
toast.error('Network error deleting patch test');
}
}
$effect(() => {
fetchData();
});
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Patch Test Management</Card.Title>
<Card.Description>
Create and manage patch test requirements and assign them to services.
</Card.Description>
</div>
<Button onclick={openAddModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add Patch Test
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="w-[25%] py-3 font-medium">Name</th>
<th class="w-[30%] py-3 font-medium">Description</th>
<th class="w-[15%] py-3 text-center font-medium">Notice (hrs)</th>
<th class="w-[15%] py-3 text-center font-medium">Expiry (months)</th>
<th class="w-[15%] py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#if loading}
{#each Array(2) as _, i (i)}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-48" /></td>
<td class="py-3"><Skeleton class="mx-auto h-4 w-12" /></td>
<td class="py-3"><Skeleton class="mx-auto h-4 w-12" /></td>
<td class="py-3 text-center">
<div class="flex justify-center gap-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</td>
</tr>
{/each}
{:else}
{#each patchTests as pt (pt.id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-medium">{pt.name}</td>
<td class="py-3 text-gray-600 truncate max-w-[200px]" title={pt.description || ''}>
{pt.description || '—'}
</td>
<td class="py-3 text-center">{pt.notice_duration_hours}</td>
<td class="py-3 text-center">{pt.expiry_months}</td>
<td class="py-3">
<div class="flex justify-center gap-2">
<Button variant="outline" size="sm" onclick={() => openEditModal(pt)}>
Edit
</Button>
<Button variant="destructive" size="sm" onclick={() => deletePatchTest(pt.id)}>
Delete
</Button>
</div>
</td>
</tr>
{/each}
{#if patchTests.length === 0}
<tr>
<td colspan="5" class="py-8 text-center text-gray-500">
No patch tests configured. Click "Add Patch Test" to create one.
</td>
</tr>
{/if}
{/if}
</tbody>
</table>
</div>
<div class="space-y-4 md:hidden">
{#if loading}
{#each Array(2) as _, i (i)}
<div class="rounded-lg border p-4 space-y-3">
<Skeleton class="h-5 w-32" />
<Skeleton class="h-4 w-48" />
<div class="flex justify-between">
<Skeleton class="h-4 w-16" />
<Skeleton class="h-4 w-16" />
</div>
</div>
{/each}
{:else}
{#each patchTests as pt (pt.id)}
<div class="rounded-lg border p-4 hover:bg-gray-50 space-y-3">
<div class="flex items-start justify-between">
<h3 class="font-medium">{pt.name}</h3>
</div>
{#if pt.description}
<p class="text-sm text-gray-600">{pt.description}</p>
{/if}
<div class="flex justify-between text-sm">
<div>
<span class="font-medium">Notice:</span> {pt.notice_duration_hours}h
</div>
<div>
<span class="font-medium">Expiry:</span> {pt.expiry_months}m
</div>
</div>
<div class="flex gap-2 pt-2">
<Button variant="outline" size="sm" onclick={() => openEditModal(pt)} class="flex-1">
Edit
</Button>
<Button variant="destructive" size="sm" onclick={() => deletePatchTest(pt.id)} class="flex-1">
Delete
</Button>
</div>
</div>
{/each}
{#if patchTests.length === 0}
<div class="py-8 text-center text-gray-500">
No patch tests configured.
</div>
{/if}
{/if}
</div>
</Card.Content>
</Card.Root>
<Modal.Root bind:open={showModal}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">{isEditing ? 'Edit' : 'Add'} Patch Test</Modal.Title>
<Modal.Description>
Configure the rules for this patch test requirement.
</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div class="space-y-2">
<label for="pt-name" class="text-sm font-medium">Name *</label>
<Input
id="pt-name"
type="text"
placeholder="e.g. Standard Glue Patch Test"
bind:value={formData.name}
class="w-full"
/>
</div>
<div class="space-y-2">
<label for="pt-description" class="text-sm font-medium">Description</label>
<Input
id="pt-description"
type="text"
placeholder="Optional details about this test"
bind:value={formData.description}
class="w-full"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<label for="pt-notice" class="text-sm font-medium">Notice Duration (hrs) *</label>
<Input
id="pt-notice"
type="number"
min="0"
bind:value={formData.notice_duration_hours}
class="w-full"
/>
<p class="text-xs text-gray-500">Hours prior to booking required</p>
</div>
<div class="space-y-2">
<label for="pt-expiry" class="text-sm font-medium">Expiry (months) *</label>
<Input
id="pt-expiry"
type="number"
min="1"
bind:value={formData.expiry_months}
class="w-full"
/>
<p class="text-xs text-gray-500">How long the test remains valid</p>
</div>
</div>
<div class="space-y-2 pt-2">
<label class="text-sm font-medium">Applicable Services</label>
<p class="text-xs text-gray-500 mb-2">Select which services require this patch test</p>
<div class="max-h-48 overflow-y-auto border rounded-md p-2 space-y-2 bg-gray-50">
{#if availableServices.length === 0}
<p class="text-sm text-gray-500 italic p-2">No services available.</p>
{:else}
{#each availableServices as svc (svc.id)}
<label class="flex items-center gap-2 p-1 hover:bg-gray-100 rounded cursor-pointer">
<Checkbox
checked={formData.service_ids.includes(svc.id)}
onCheckedChange={() => toggleServiceSelection(svc.id)}
/>
<span class="text-sm">{svc.name}</span>
</label>
{/each}
{/if}
</div>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (showModal = false)} disabled={isSubmitting}>
Cancel
</Button>
<Button onclick={savePatchTest} disabled={isSubmitting || !formData.name.trim()}>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>