feat: complete PatchTestsManagement UI and modal logic
This commit is contained in:
@@ -7,6 +7,11 @@
|
||||
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;
|
||||
@@ -17,27 +22,153 @@
|
||||
};
|
||||
|
||||
let patchTests = $state<PatchTest[]>([]);
|
||||
let availableServices = $state<Service[]>([]);
|
||||
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;
|
||||
try {
|
||||
const response = await fetch('/api/admin/patch-tests', {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
patchTests = await response.json();
|
||||
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();
|
||||
availableServices = await svcRes.json();
|
||||
} 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 {
|
||||
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(() => {
|
||||
fetchPatchTests();
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -46,46 +177,225 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<Skeleton class="h-20 w-full" />
|
||||
{:else}
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full table-auto border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-gray-500">
|
||||
<th class="py-3">Name</th>
|
||||
<th class="py-3">Notice (hrs)</th>
|
||||
<th class="py-3">Expiry (months)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each patchTests as pt}
|
||||
|
||||
<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 font-medium">{pt.name}</td>
|
||||
<td class="py-3">{pt.notice_duration_hours}</td>
|
||||
<td class="py-3">{pt.expiry_months}</td>
|
||||
<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}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{: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>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user