Files
Crussell/frontend/src/lib/components/admin/EditRequestModal.svelte
T
popertots 4af2b8dfb4
Backend CI / Tests (push) Failing after 1m41s
Backend CI / Lint & vulns (push) Failing after 2m26s
Backend CI / Race detector (push) Failing after 3m45s
style: fix prefer-const and prettier formatting issues
2026-06-25 13:48:03 +01:00

404 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
interface ServiceItem {
id: string;
name: string;
price: number;
duration_minutes: number;
}
interface EditRequest {
id: string;
booking_id: string;
requested_by: string;
requested_at: string;
notes: string | null;
original: {
start_time: string;
end_time: string;
services: ServiceItem[];
notes: string;
};
proposed: {
start_time: string | null;
end_time: string | null;
services: ServiceItem[];
notes: string | null;
};
user: {
id: string;
full_name: string;
email: string;
phone: string;
previous_first_name?: string | null;
previous_last_name?: string | null;
};
}
interface Props {
open: boolean;
editRequest: EditRequest;
onApproved: () => void;
onDenied: () => void;
}
let { open = $bindable(), editRequest, onApproved, onDenied }: Props = $props();
let submitting = $state(false);
let showDenyConfirm = $state(false);
function formatDateLine1(dateTimeString: string): string {
const d = parseWallClockDate(dateTimeString);
const dateStr = d.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
const startTime = d.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
return `${dateStr} at ${startTime}`;
}
function formatDateLine2(dateTimeString: string, durationMinutes: number): string {
const d = parseWallClockDate(dateTimeString);
const endMinutes = d.getHours() * 60 + d.getMinutes() + durationMinutes;
const endH = Math.floor(endMinutes / 60);
const endM = endMinutes % 60;
const endPeriod = endH >= 12 ? 'pm' : 'am';
const endDisplayH = endH % 12 || 12;
const endTime = `${endDisplayH}:${String(endM).padStart(2, '0')} ${endPeriod}`;
return `${endTime}, ${durationMinutes} minutes`;
}
function getDuration(services: ServiceItem[]): number {
return services.reduce((sum, s) => sum + s.duration_minutes, 0);
}
function isTimeChanged(): boolean {
if (!editRequest.proposed.start_time) return false;
return editRequest.proposed.start_time !== editRequest.original.start_time;
}
function areServicesChanged(): boolean {
const origIds = new Set(editRequest.original.services.map((s) => s.id));
const propIds = new Set(editRequest.proposed.services.map((s) => s.id));
if (origIds.size !== propIds.size) return true;
for (const id of origIds) {
if (!propIds.has(id)) return true;
}
return false;
}
const serviceDiff = $derived.by(() => {
const orig = editRequest.original.services;
const prop = editRequest.proposed.services;
const origIds = new Set(orig.map((s) => s.id));
const propIds = new Set(prop.map((s) => s.id));
return {
removed: orig.filter((s) => !propIds.has(s.id)),
added: prop.filter((s) => !origIds.has(s.id)),
same: orig.filter((s) => propIds.has(s.id))
};
});
async function handleApprove() {
submitting = true;
const loadingToast = toast.loading('Approving change request...');
try {
const response = await fetch(
`/api/admin/bookings/${editRequest.booking_id}/edit-requests/${editRequest.id}/approve`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (response.ok) {
toast.success('Change request approved!', { id: loadingToast });
open = false;
onApproved();
} else {
const text = await response.text();
toast.error('Failed to approve: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error approving edit request:', err);
toast.error('Network error approving change request', { id: loadingToast });
} finally {
submitting = false;
}
}
async function handleDeny() {
submitting = true;
const loadingToast = toast.loading('Denying change request...');
try {
const response = await fetch(
`/api/admin/bookings/${editRequest.booking_id}/edit-requests/${editRequest.id}/deny`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}
);
if (response.ok) {
toast.success('Change request denied', { id: loadingToast });
showDenyConfirm = false;
open = false;
onDenied();
} else {
const text = await response.text();
toast.error('Failed to deny: ' + text, { id: loadingToast });
}
} catch (err) {
console.error('Error denying edit request:', err);
toast.error('Network error denying change request', { id: loadingToast });
} finally {
submitting = false;
showDenyConfirm = false;
}
}
</script>
<Modal.Root bind:open>
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Booking Change Request</Modal.Title>
<Modal.Description>
Review the requested changes to {formatUserName(
editRequest.user.full_name,
editRequest.user.previous_first_name,
editRequest.user.previous_last_name
)}'s booking.
</Modal.Description>
</Modal.Header>
<div class="space-y-6 px-4 pb-4">
<!-- Customer Contact Info -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Customer Contact
</h3>
<div class="space-y-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">
{formatUserName(
editRequest.user.full_name,
editRequest.user.previous_first_name,
editRequest.user.previous_last_name
)}
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">
{#if editRequest.user.phone}
<a href="tel:{editRequest.user.phone}" class="text-blue-600 hover:underline"
>{editRequest.user.phone}</a
>
{:else}{/if}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">
{#if editRequest.user.email}
<a href="mailto:{editRequest.user.email}" class="text-blue-600 hover:underline"
>{editRequest.user.email}</a
>
{:else}{/if}
</div>
</div>
</div>
</div>
</div>
{#if isTimeChanged()}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Date & Time Change
</h3>
<div class="space-y-3">
<div>
<div class="mb-1 text-xs text-gray-500">Before</div>
<div class="font-medium">
{formatDateLine1(editRequest.original.start_time)}
</div>
<div class="text-sm text-gray-600">
{formatDateLine2(
editRequest.original.start_time,
getDuration(editRequest.original.services)
)}
</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">After</div>
<div class="font-medium text-emerald-700">
{formatDateLine1(editRequest.proposed.start_time!)}
</div>
<div class="text-sm text-gray-600">
{formatDateLine2(
editRequest.proposed.start_time!,
getDuration(editRequest.proposed.services)
)}
</div>
</div>
</div>
</div>
{/if}
{#if areServicesChanged()}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Services Change
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs text-gray-500">Original</div>
<div class="space-y-2">
{#each editRequest.original.services as service (service.id)}
{#if serviceDiff.removed.some((s) => s.id === service.id)}
<div class="flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
<span class="mt-0.5 font-mono text-sm text-red-600"></span>
<div class="flex-1">
<div class="text-sm font-medium text-red-700 line-through">
{service.name}
</div>
<div class="text-xs text-red-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
{:else}
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
<div class="flex-1">
<div class="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
{/if}
{/each}
</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Proposed</div>
<div class="space-y-2">
{#each editRequest.proposed.services as service (service.id)}
{#if serviceDiff.added.some((s) => s.id === service.id)}
<div
class="flex items-start gap-2 rounded border border-emerald-200 bg-emerald-50 p-2"
>
<span class="mt-0.5 font-mono text-sm text-emerald-600">+</span>
<div class="flex-1">
<div class="text-sm font-medium text-emerald-700">{service.name}</div>
<div class="text-xs text-emerald-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
{:else}
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2">
<span class="mt-0.5 font-mono text-sm text-gray-400"> </span>
<div class="flex-1">
<div class="text-sm font-medium">{service.name}</div>
<div class="text-xs text-gray-600">
£{service.price.toFixed(2)} &middot; {service.duration_minutes} min
</div>
</div>
</div>
{/if}
{/each}
</div>
</div>
</div>
</div>
{/if}
{#if editRequest.proposed.notes !== editRequest.original.notes}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Booking Notes Change
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-xs text-gray-500">Original</div>
<div class="text-sm">{editRequest.original.notes || '—'}</div>
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Proposed</div>
<div class="text-sm">{editRequest.proposed.notes}</div>
</div>
</div>
</div>
{/if}
<!-- Request Notes -->
{#if editRequest.notes}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Reason for Change
</h3>
<p class="text-sm text-gray-700">{editRequest.notes}</p>
</div>
{/if}
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="destructive" onclick={() => (showDenyConfirm = true)} disabled={submitting}>
Deny
</Button>
<Button
onclick={handleApprove}
disabled={submitting}
class="bg-emerald-600 hover:bg-emerald-700"
>
{submitting ? 'Approving...' : 'Approve'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<!-- Deny Confirmation Dialog -->
<AlertDialog.Root bind:open={showDenyConfirm}>
<AlertDialog.Content class="z-60">
<AlertDialog.Header>
<AlertDialog.Title>Deny this change request?</AlertDialog.Title>
<AlertDialog.Description>
This will reject the requested changes and notify the customer. This action cannot be
undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={handleDeny} class="bg-red-600 hover:bg-red-700">
Deny Request
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>