feat: enriched edit request system with side-by-side snapshots, calendar preloading, and admin review UI

Backend:
- Add enriched response types (EditSnapshot, EnrichedEditRequest) with original vs proposed snapshots
- Add 4 new GET endpoints for viewing edit requests (user and admin scoped)
- Remove github.com/lib/pq dependency — use native PostgreSQL array scanning
- Clean up edit requests, time blockers, and notifications on booking cancellation
- Validate exceptional closed hours on admin approve (409 Conflict)
- Notification upsert on edit request replace (no duplicate admin notifications)

Frontend:
- New user EditRequestModal with time/services/both modes and lunch protection
- New admin EditRequestModal with side-by-side diff (date/time, services, notes)
- Integrate edit requests into PendingApprovals card and notifications page
- Preload 3 months of availability to prevent calendar snap-back
- Apply lunch protection to isDateUnavailable in BookingFlow and BookingCreateModal
- Fix accessibility: card list items use <button> instead of <div>

Dev & Docs:
- Seed edit requests in local-dev-2.sh
- Update all Obsidian manuals with enriched edit request documentation
- 42 new tests (438/441 passing)
This commit is contained in:
2026-05-26 11:59:07 +01:00
parent 3ccc017716
commit 8574bf2221
19 changed files with 5270 additions and 599 deletions
@@ -7,6 +7,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Skeleton } from '$lib/components/ui/skeleton';
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte';
interface Props {
openBookingModal?: (bookingId: string) => void;
@@ -14,6 +15,40 @@
let { openBookingModal }: Props = $props();
// Edit request types
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;
};
}
// Match the backend structure
type PendingApproval = {
id: string;
@@ -51,6 +86,11 @@
let showApprovalModal = $state(false);
let selectedBooking = $state<PendingBooking | null>(null);
let pendingEditRequests = $state<EditRequest[]>([]);
let visibleEditRequests = $derived(pendingEditRequests.slice(0, 3));
let showEditRequestModal = $state(false);
let selectedEditRequest = $state<EditRequest | null>(null);
// Helper function to format date nicely
function formatDateTime(dateTimeString: string): string {
const date = new SvelteDate(dateTimeString);
@@ -67,6 +107,59 @@
return `${dateStr} at ${timeStr}`;
}
function formatRelativeTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDay = Math.floor(diffHr / 24);
return `${diffDay}d ago`;
}
function getEditRequestSummary(er: EditRequest): string {
const timeChanged = er.proposed.start_time && er.proposed.start_time !== er.original.start_time;
const servicesChanged = areEditServicesChanged(er);
if (timeChanged) {
const d = new Date(er.proposed.start_time!);
const dateStr = d.toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
month: 'short'
});
const timeStr = d.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
return `Requested change to ${dateStr} at ${timeStr}`;
}
if (servicesChanged) {
return 'Requested change to services';
}
return 'Requested change';
}
function areEditServicesChanged(er: EditRequest): boolean {
const origIds = new Set(er.original.services.map((s) => s.id));
const propIds = new Set(er.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;
}
function getServiceSummary(er: EditRequest): string {
const names = er.proposed.services.map((s) => s.name).filter(Boolean);
return names.join(', ') || 'No services';
}
async function fetchPendingApprovals() {
loading = true;
try {
@@ -81,7 +174,8 @@
if (response.ok) {
const data = await response.json();
pendingApprovals = (data.approvals || []).sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
(a: PendingApproval, b: PendingApproval) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
} else {
toast.error('Failed to load pending approvals');
@@ -94,6 +188,28 @@
}
}
async function fetchEditRequests() {
try {
const response = await fetch('/api/admin/bookings/edit-requests', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
const data = await response.json();
pendingEditRequests = (data.edit_requests || []).sort(
(a: EditRequest, b: EditRequest) =>
new Date(a.requested_at).getTime() - new Date(b.requested_at).getTime()
);
}
} catch (err) {
console.error('Error fetching edit requests:', err);
}
}
async function openApprovalModal(bookingId: string) {
try {
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
@@ -112,11 +228,18 @@
}
}
function openReviewModal(editRequest: EditRequest) {
selectedEditRequest = editRequest;
showEditRequestModal = true;
}
$effect(() => {
fetchPendingApprovals();
fetchEditRequests();
const intervalId = setInterval(() => {
fetchPendingApprovals();
fetchEditRequests();
}, 60_000);
return () => {
@@ -144,11 +267,21 @@
</svg>
Pending Approvals
</Card.Title>
<Card.Description>New bookings awaiting confirmation</Card.Description>
<Card.Description>
{#if pendingApprovals.length > 0 && pendingEditRequests.length > 0}
New bookings and customer-requested changes awaiting review
{:else if pendingApprovals.length > 0}
New bookings awaiting confirmation
{:else if pendingEditRequests.length > 0}
Customer-requested booking changes awaiting review
{:else}
New bookings awaiting confirmation
{/if}
</Card.Description>
</div>
{#if !loading}
<Badge class="bg-amber-100 text-amber-800 hover:bg-amber-100">
{pendingApprovals.length}
{pendingApprovals.length + pendingEditRequests.length}
</Badge>
{/if}
</div>
@@ -169,7 +302,7 @@
</div>
{/each}
</div>
{:else if pendingApprovals.length === 0}
{:else if pendingApprovals.length === 0 && pendingEditRequests.length === 0}
<div class="py-8 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -186,7 +319,7 @@
/>
</svg>
<p class="text-sm font-medium text-gray-600">All caught up!</p>
<p class="text-xs text-gray-500">No pending bookings to review</p>
<p class="text-xs text-gray-500">Nothing pending to review</p>
</div>
{:else}
<div class="space-y-3">
@@ -229,6 +362,43 @@
</div>
</div>
{/each}
{#if pendingApprovals.length > 0 && pendingEditRequests.length > 0}
<div class="border-t border-gray-200 pt-3"></div>
{/if}
{#each visibleEditRequests as er (er.id)}
<div
class="rounded-lg border border-amber-200 bg-amber-50/30 p-3 transition-all hover:shadow-md"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{er.user?.full_name || 'Unknown'}</span>
<span class="text-xs text-amber-600 font-medium">Edit Request</span>
</div>
<div class="mt-1 text-sm text-gray-600">
{getEditRequestSummary(er)}
</div>
<div class="mt-1 text-xs text-gray-500">
{getServiceSummary(er)}
</div>
<div class="mt-1 text-xs text-gray-400">
Requested {formatRelativeTime(er.requested_at)}
</div>
</div>
<div class="flex flex-col gap-2">
<Button
size="sm"
onclick={() => openReviewModal(er)}
class="bg-amber-600 hover:bg-amber-700"
>
Review
</Button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</Card.Content>
@@ -246,3 +416,23 @@
}}
/>
{/if}
<!-- Edit Request Modal -->
{#if selectedEditRequest && showEditRequestModal}
<EditRequestModal
bind:open={showEditRequestModal}
editRequest={selectedEditRequest}
onApproved={() => {
showEditRequestModal = false;
selectedEditRequest = null;
fetchPendingApprovals();
fetchEditRequests();
}}
onDenied={() => {
showEditRequestModal = false;
selectedEditRequest = null;
fetchPendingApprovals();
fetchEditRequests();
}}
/>
{/if}