feat: complete PatchTestsManagement UI and modal logic

This commit is contained in:
2026-05-29 18:04:27 +01:00
parent df32f5518c
commit db4c3aa076
@@ -7,6 +7,11 @@
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import * as Modal from '$lib/components/ui/dialog'; import * as Modal from '$lib/components/ui/dialog';
type Service = {
id: string;
name: string;
};
type PatchTest = { type PatchTest = {
id: string; id: string;
name: string; name: string;
@@ -17,27 +22,153 @@
}; };
let patchTests = $state<PatchTest[]>([]); let patchTests = $state<PatchTest[]>([]);
let availableServices = $state<Service[]>([]);
let loading = $state(true); let loading = $state(true);
let showModal = $state(false); let isSubmitting = $state(false);
async function fetchPatchTests() { 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; loading = true;
try { try {
const response = await fetch('/api/admin/patch-tests', { const [ptRes, svcRes] = await Promise.all([
headers: { Authorization: `Bearer ${authStore.currentToken}` } fetch('/api/admin/patch-tests', {
}); headers: { Authorization: `Bearer ${authStore.currentToken}` }
if (response.ok) { }),
patchTests = await response.json(); fetch('/api/admin/services', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
})
]);
if (ptRes.ok && svcRes.ok) {
patchTests = await ptRes.json();
availableServices = await svcRes.json();
} else { } else {
toast.error('Failed to load patch tests'); toast.error('Failed to load patch test data');
} }
} catch (err) {
console.error(err);
toast.error('Network error loading data');
} finally { } finally {
loading = false; 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(() => { $effect(() => {
fetchPatchTests(); fetchData();
}); });
</script> </script>
@@ -46,46 +177,225 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Patch Test Management</Card.Title> <Card.Title>Patch Test Management</Card.Title>
<Card.Description>Manage patch test requirements.</Card.Description> <Card.Description>
Create and manage patch test requirements and assign them to services.
</Card.Description>
</div> </div>
<Button onclick={() => showModal = true}>Add Patch Test</Button> <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> </div>
</Card.Header> </Card.Header>
<Card.Content>
{#if loading} <Card.Content class="space-y-4">
<Skeleton class="h-20 w-full" /> <div class="hidden w-full overflow-x-auto md:block">
{:else} <table class="w-full table-auto border-collapse text-sm">
<div class="w-full overflow-x-auto"> <thead>
<table class="w-full table-auto border-collapse text-sm"> <tr class="border-b text-left text-xs text-gray-500">
<thead> <th class="w-[25%] py-3 font-medium">Name</th>
<tr class="border-b text-left text-xs text-gray-500"> <th class="w-[30%] py-3 font-medium">Description</th>
<th class="py-3">Name</th> <th class="w-[15%] py-3 text-center font-medium">Notice (hrs)</th>
<th class="py-3">Notice (hrs)</th> <th class="w-[15%] py-3 text-center font-medium">Expiry (months)</th>
<th class="py-3">Expiry (months)</th> <th class="w-[15%] py-3 text-center font-medium">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each patchTests as pt} {#if loading}
{#each Array(2) as _, i (i)}
<tr class="border-b"> <tr class="border-b">
<td class="py-3 font-medium">{pt.name}</td> <td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3">{pt.notice_duration_hours}</td> <td class="py-3"><Skeleton class="h-4 w-48" /></td>
<td class="py-3">{pt.expiry_months}</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> </tr>
{/each} {/each}
</tbody> {:else}
</table> {#each patchTests as pt (pt.id)}
</div> <tr class="border-b hover:bg-gray-50">
{/if} <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.Content>
</Card.Root> </Card.Root>
<Modal.Root bind:open={showModal}> <Modal.Root bind:open={showModal}>
<Modal.Content> <Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
<Modal.Header> <Modal.Header>
<Modal.Title>Add Patch Test</Modal.Title> <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> </Modal.Header>
<div class="p-4">
<p>Patch test creation modal content goes here...</p> <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">
<input
type="checkbox"
class="rounded border-gray-300 text-black focus:ring-black"
checked={formData.service_ids.includes(svc.id)}
onchange={() => toggleServiceSelection(svc.id)}
/>
<span class="text-sm">{svc.name}</span>
</label>
{/each}
{/if}
</div>
</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.Content>
</Modal.Root> </Modal.Root>