services WIP

This commit is contained in:
2025-10-18 00:45:57 +01:00
parent 1371fec036
commit 69b8c0cbe5
3 changed files with 689 additions and 47 deletions
+303
View File
@@ -464,6 +464,126 @@
dest.push(isoDateOf(new Date(d)));
}
}
// =============== Services Management ===============
type Service = {
id: string;
name: string;
description: string;
price: number;
duration_minutes: number;
is_active: boolean;
patch_test_duration_hours: number;
minimum_age_required: number;
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
};
let services = $state<Service[]>([]);
let servicesLoading = $state(true);
let servicesUpdating = $state<Record<string, boolean>>({});
let showServiceModal = $state(false);
// Fetch services from API
async function fetchServices() {
servicesLoading = true;
try {
const response = await fetch('/api/admin/services', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('authToken')}`
}
});
if (response.ok) {
const data = await response.json();
services = data;
} else {
console.error('Failed to fetch services:', response.status);
toast.error('Failed to load services');
}
} catch (err) {
console.error('Error fetching services:', err);
toast.error('Network error loading services');
} finally {
servicesLoading = false;
}
}
// Toggle service active status
async function toggleService(serviceId: string) {
servicesUpdating[serviceId] = true;
try {
const response = await fetch(`/api/admin/services/${serviceId}/toggle`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${localStorage.getItem('authToken')}`
}
});
if (response.ok) {
toast.success('Service status updated');
// Refresh the services list
await fetchServices();
} else {
const errorText = await response.text();
toast.error(`Failed to update service: ${errorText}`);
}
} catch (err) {
console.error('Error toggling service:', err);
toast.error('Network error updating service');
} finally {
servicesUpdating[serviceId] = false;
}
}
// Delete service
async function deleteService(serviceId: string) {
if (!confirm('Are you sure you want to delete this service? This action cannot be undone.')) {
return;
}
servicesUpdating[serviceId] = true;
try {
const response = await fetch(`/api/admin/services/${serviceId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${localStorage.getItem('authToken')}`
}
});
if (response.ok) {
toast.success('Service deleted successfully');
// Refresh the services list
await fetchServices();
} else {
const errorText = await response.text();
toast.error(`Failed to delete service: ${errorText}`);
}
} catch (err) {
console.error('Error deleting service:', err);
toast.error('Network error deleting service');
} finally {
servicesUpdating[serviceId] = false;
}
}
// Create new service
function createNewService() {
// TODO: Implement service creation modal
toast.info('Service creation modal would open here');
// showServiceModal = true;
}
// Fetch services on component mount
$effect(() => {
fetchServices();
});
</script>
{#if user?.role == 'admin'}
@@ -737,6 +857,189 @@
</Card.Content>
{/if}
</Card.Root>
<!-- Services Management Card -->
<Card.Root>
<Card.Header>
<Card.Title>Services Management</Card.Title>
<Card.Description
>Manage your services - add, edit, toggle availability, or delete services.</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex items-center justify-between">
<Button onclick={createNewService}>Add Service</Button>
</div>
<!-- Desktop Table -->
<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-[20%] py-3 font-medium">Name</th>
<th class="w-[30%] py-3 font-medium">Description</th>
<th class="w-[10%] py-3 text-right font-medium">Price</th>
<th class="w-[12%] py-3 text-right font-medium">Duration</th>
<th class="w-[12%] py-3 text-center font-medium">Status</th>
<th class="w-[16%] py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#if servicesLoading}
{#each Array(3) as _, 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 text-right"><Skeleton class="ml-auto h-4 w-16" /></td>
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-20" /></td>
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-16" /></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 services as service}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-medium">{service.name}</td>
<td class="py-3 text-gray-600">
{#if service.description}
<div class="line-clamp-2" title={service.description}>
{service.description}
</div>
{:else}
<span class="text-gray-400">—</span>
{/if}
</td>
<td class="py-3 text-right font-medium">£{service.price.toFixed(2)}</td>
<td class="py-3 text-right">{service.duration_minutes} min</td>
<td class="py-3 text-center">
<span
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {service.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'}"
>
{service.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td class="py-3">
<div class="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => toggleService(service.id)}
disabled={servicesUpdating[service.id]}
>
{servicesUpdating[service.id]
? '...'
: service.is_active
? 'Deactivate'
: 'Activate'}
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => deleteService(service.id)}
disabled={servicesUpdating[service.id]}
>
Delete
</Button>
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Mobile Cards (keep the same as before) -->
<div class="space-y-4 md:hidden">
{#if servicesLoading}
{#each Array(3) as _, i}
<div class="rounded-lg border p-4">
<div class="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-20" />
</div>
<div class="flex gap-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</div>
</div>
{/each}
{:else}
{#each services as service}
<div class="rounded-lg border p-4 hover:bg-gray-50">
<div class="space-y-3">
<div class="flex items-start justify-between">
<h3 class="font-medium">{service.name}</h3>
<span
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {service.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'}"
>
{service.is_active ? 'Active' : 'Inactive'}
</span>
</div>
{#if service.description}
<p class="text-sm text-gray-600">{service.description}</p>
{/if}
<div class="flex justify-between text-sm">
<div>
<span class="font-medium">Price:</span> £{service.price.toFixed(2)}
</div>
<div>
<span class="font-medium">Duration:</span>
{service.duration_minutes} min
</div>
</div>
<div class="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onclick={() => toggleService(service.id)}
disabled={servicesUpdating[service.id]}
class="flex-1"
>
{servicesUpdating[service.id]
? '...'
: service.is_active
? 'Deactivate'
: 'Activate'}
</Button>
<Button
variant="destructive"
size="sm"
onclick={() => deleteService(service.id)}
disabled={servicesUpdating[service.id]}
class="flex-1"
>
Delete
</Button>
</div>
</div>
</div>
{/each}
{/if}
</div>
{#if !servicesLoading && services.length === 0}
<div class="py-8 text-center text-gray-500">
No services found. Click "Add Service" to create your first service.
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Default Hours Modal -->