feat: migrate to dedicated patch test management

This commit is contained in:
2026-05-29 17:19:02 +01:00
parent 9d8015f1b8
commit f36497090a
8 changed files with 450 additions and 124 deletions
@@ -0,0 +1,91 @@
<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 { Skeleton } from '$lib/components/ui/skeleton';
import * as Modal from '$lib/components/ui/dialog';
type PatchTest = {
id: string;
name: string;
description: string | null;
notice_duration_hours: number;
expiry_months: number;
service_ids: string[];
};
let patchTests = $state<PatchTest[]>([]);
let loading = $state(true);
let showModal = $state(false);
async function fetchPatchTests() {
loading = true;
try {
const response = await fetch('/api/admin/patch-tests', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (response.ok) {
patchTests = await response.json();
} else {
toast.error('Failed to load patch tests');
}
} finally {
loading = false;
}
}
$effect(() => {
fetchPatchTests();
});
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Patch Test Management</Card.Title>
<Card.Description>Manage patch test requirements.</Card.Description>
</div>
<Button onclick={() => showModal = true}>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}
<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>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Card.Content>
</Card.Root>
<Modal.Root bind:open={showModal}>
<Modal.Content>
<Modal.Header>
<Modal.Title>Add Patch Test</Modal.Title>
</Modal.Header>
<div class="p-4">
<p>Patch test creation modal content goes here...</p>
</div>
</Modal.Content>
</Modal.Root>
@@ -20,55 +20,27 @@
patch_test_duration_hours: number;
minimum_age_required: number;
created_at: string;
updated_at?: string;
created_by?: string;
updated_by?: string;
};
// =============== State ===============
let services = $state<Service[]>([]);
let servicesLoading = $state(true);
let servicesUpdating = $state<Record<string, boolean>>({});
// Service Creation State
let showServiceModal = $state(false);
let creatingService = $state(false);
let newService = $state({
let newService = $state<Omit<Service, 'id' | 'created_at' | 'created_by' | 'patch_test_duration_hours'>>({
name: '',
description: '',
price: '',
duration_minutes: 60,
patch_test_duration_hours: 0,
minimum_age_required: 0
price: 0,
duration_minutes: 0,
is_active: true,
minimum_age_required: 0,
});
let editingService = $state<Service | null>(null);
let loading = $state(true);
let serviceErrors = $state<Record<string, string>>({});
let serviceErrors = $state({
name: '',
price: '',
duration_minutes: '',
patch_test_duration_hours: '',
minimum_age_required: ''
});
function validateDuration(val: any, field: string) {
if (typeof val !== 'number' || val < 0) return 'Duration must be a positive number';
return '';
}
// =============== Validation ===============
let isFormValid = $derived(
newService.name.trim() !== '' &&
/^\d+(\.\d{1,2})?$/.test(newService.price) &&
parseFloat(newService.price) > 0 &&
Number.isInteger(newService.duration_minutes) &&
newService.duration_minutes > 0 &&
Number.isInteger(newService.patch_test_duration_hours) &&
newService.patch_test_duration_hours >= 0 &&
Number.isInteger(newService.minimum_age_required) &&
newService.minimum_age_required >= 0 &&
newService.minimum_age_required <= 100
);
function validatePrice(price: string): string {
const validFormat = /^\d*\.?\d*$/.test(price);
if (!validFormat) {
return 'Price must be a valid number (e.g., 4.50)';
}
const numPrice = parseFloat(price);
if (isNaN(numPrice)) {
@@ -143,12 +115,97 @@
}
function validateDurationField() {
serviceErrors.duration_minutes = validateDuration(
newService.duration_minutes,
'duration_minutes'
);
serviceErrors.duration_minutes = validateDuration(newService.duration_minutes, 'duration_minutes');
}
function validateMinimumAgeField() {
serviceErrors.minimum_age_required = validateDuration(newService.minimum_age_required, 'minimum_age_required');
}
function validateNameField() {
if (!newService.name) serviceErrors.name = 'Name is required';
else serviceErrors.name = '';
}
function validatePriceField() {
if (typeof newService.price !== 'number' || newService.price <= 0) serviceErrors.price = 'Price must be greater than 0';
else serviceErrors.price = '';
}
let isFormValid = $derived(
newService.name !== '' &&
!serviceErrors.name &&
!serviceErrors.price &&
!serviceErrors.duration_minutes &&
!serviceErrors.minimum_age_required
);
let creatingService = $state(false);
async function createService() {
creatingService = true;
const loadingToast = toast.loading('Creating service...');
const payload = {
...newService,
price: Number(newService.price),
duration_minutes: Number(newService.duration_minutes),
minimum_age_required: Number(newService.minimum_age_required)
};
try {
const response = await fetch('/api/admin/services', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(payload)
});
if (response.ok) {
await response.json();
toast.success('Service created successfully!', { id: loadingToast });
resetServiceForm();
showServiceModal = false;
await fetchServices();
} else if (response.status === 409) {
toast.error('A service with this name already exists', { id: loadingToast });
} else if (response.status === 400) {
const errorText = await response.text();
toast.error(`Validation error: ${errorText}`, { id: loadingToast });
} else {
const errorText = await response.text();
toast.error(`Failed to create service: ${errorText}`, { id: loadingToast });
}
} catch (err) {
console.error('Error creating service:', err);
toast.error('Network error creating service', { id: loadingToast });
} finally {
creatingService = false;
}
}
function resetServiceForm() {
newService = {
name: '',
description: '',
price: 0,
duration_minutes: 60,
is_active: true,
minimum_age_required: 0
};
serviceErrors = {
name: '',
price: '',
duration_minutes: '',
minimum_age_required: ''
};
}
function validatePatchTestField() {
serviceErrors.patch_test_duration_hours = validateDuration(
newService.patch_test_duration_hours,
@@ -618,27 +675,6 @@
<!-- Patch Test and Minimum Age - Side by side on desktop -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Patch Test Duration -->
<div class="space-y-2">
<label for="patch-test-duration" class="text-sm font-medium"
>Patch Test Duration (hours)</label
>
<Input
id="patch-test-duration"
type="number"
min="0"
step="1"
placeholder="0"
bind:value={newService.patch_test_duration_hours}
onblur={validatePatchTestField}
class="w-full border-red-500={serviceErrors.patch_test_duration_hours}"
/>
{#if serviceErrors.patch_test_duration_hours}
<p class="text-sm text-red-600">{serviceErrors.patch_test_duration_hours}</p>
{/if}
<p class="text-xs text-gray-500">Hours required before service (0 for none)</p>
</div>
<!-- Minimum Age -->
<div class="space-y-2">
<label for="minimum-age" class="text-sm font-medium">Minimum Age</label>