Skip to content

Commit ad98ad3

Browse files
committed
feat(bookings): implement booking editing functionality with metadata support
1 parent 7ab8457 commit ad98ad3

10 files changed

Lines changed: 639 additions & 111 deletions

File tree

apps/management-ui/app/bookings/page.tsx

Lines changed: 173 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,14 @@ import { PageSpinner } from "@/components/ui/spinner";
3030
import { useToast } from "@/components/ui/toast";
3131
import { tenantsApi } from "@/lib/api/tenants";
3232
import {
33-
bookingsApi, type Booking, type BookingStatus, type CreateBookingBody,
33+
bookingsApi,
34+
type Booking,
35+
type BookingStatus,
36+
type CreateBookingBody,
37+
type EditBookingBody,
3438
} from "@/lib/api/bookings";
3539
import { formatDate } from "@/lib/utils";
36-
import { CalendarDays, Plus, Trash2, RefreshCw } from "lucide-react";
40+
import { CalendarDays, Plus, Trash2, RefreshCw, Pencil } from "lucide-react";
3741

3842
const NEXT_STATUSES: Record<BookingStatus, BookingStatus[]> = {
3943
pending: ["confirmed", "cancelled"],
@@ -43,13 +47,14 @@ const NEXT_STATUSES: Record<BookingStatus, BookingStatus[]> = {
4347
no_show: [],
4448
};
4549

46-
const createSchema = z.object({
50+
const bookingFormSchema = z.object({
4751
customerRef: z.string().min(1, "Required"),
4852
serviceRef: z.string().min(1, "Required"),
4953
slotStart: z.string().min(1, "Required"),
5054
slotEnd: z.string().min(1, "Required"),
55+
metadata: z.string().optional(),
5156
});
52-
type CreateForm = z.infer<typeof createSchema>;
57+
type BookingForm = z.infer<typeof bookingFormSchema>;
5358

5459
const statusFilterOptions = [
5560
{ value: "", label: "All statuses" },
@@ -69,8 +74,10 @@ export default function BookingsPage() {
6974
useEffect(() => {
7075
if (activeTenant?.id) { setSelectedTenantId(activeTenant.id); setPage(0); }
7176
}, [activeTenant?.id]);
77+
7278
const [statusFilter, setStatusFilter] = useState("");
7379
const [createOpen, setCreateOpen] = useState(false);
80+
const [editTarget, setEditTarget] = useState<Booking | null>(null);
7481
const [cancelTarget, setCancelTarget] = useState<Booking | null>(null);
7582
const [updateTarget, setUpdateTarget] = useState<Booking | null>(null);
7683
const [page, setPage] = useState(0);
@@ -101,14 +108,32 @@ export default function BookingsPage() {
101108
enabled: !!selectedTenantId,
102109
});
103110

111+
// ── Forms ──────────────────────────────────────────────────────────────────
112+
const createForm = useForm<BookingForm>({ resolver: zodResolver(bookingFormSchema) });
113+
const { register, handleSubmit, formState: { errors } } = createForm;
114+
115+
const editForm = useForm<BookingForm>({ resolver: zodResolver(bookingFormSchema) });
116+
117+
// ── Mutations ──────────────────────────────────────────────────────────────
104118
const createMutation = useMutation({
105119
mutationFn: (body: CreateBookingBody) =>
106120
bookingsApi.create(body, selectedTenantId),
107121
onSuccess: () => {
108122
toast("Booking created", "success");
109123
qc.invalidateQueries({ queryKey: ["bookings", selectedTenantId] });
110124
setCreateOpen(false);
111-
reset();
125+
createForm.reset();
126+
},
127+
onError: (e: Error) => toast(e.message, "error"),
128+
});
129+
130+
const editMutation = useMutation({
131+
mutationFn: ({ bid, body }: { bid: string; body: EditBookingBody }) =>
132+
bookingsApi.edit(bid, body, selectedTenantId),
133+
onSuccess: () => {
134+
toast("Booking updated", "success");
135+
qc.invalidateQueries({ queryKey: ["bookings", selectedTenantId] });
136+
setEditTarget(null);
112137
},
113138
onError: (e: Error) => toast(e.message, "error"),
114139
});
@@ -134,12 +159,43 @@ export default function BookingsPage() {
134159
onError: (e: Error) => toast(e.message, "error"),
135160
});
136161

137-
const {
138-
register,
139-
handleSubmit,
140-
reset,
141-
formState: { errors },
142-
} = useForm<CreateForm>({ resolver: zodResolver(createSchema) });
162+
// ── Edit helpers ───────────────────────────────────────────────────────────
163+
const openEdit = (b: Booking) => {
164+
const toLocal = (iso: string) => {
165+
const d = new Date(iso);
166+
const pad = (n: number) => String(n).padStart(2, "0");
167+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
168+
};
169+
editForm.reset({
170+
customerRef: b.customerRef,
171+
serviceRef: b.serviceRef,
172+
slotStart: toLocal(b.slotStart),
173+
slotEnd: toLocal(b.slotEnd),
174+
metadata: Object.keys(b.metadata ?? {}).length > 0
175+
? JSON.stringify(b.metadata, null, 2)
176+
: "",
177+
});
178+
setEditTarget(b);
179+
};
180+
181+
const submitEdit = editForm.handleSubmit((v) => {
182+
if (!editTarget) return;
183+
let parsedMeta: Record<string, unknown> = {};
184+
if (v.metadata?.trim()) {
185+
try { parsedMeta = JSON.parse(v.metadata); }
186+
catch { editForm.setError("metadata", { message: "Invalid JSON" }); return; }
187+
}
188+
editMutation.mutate({
189+
bid: editTarget.id,
190+
body: {
191+
customerRef: v.customerRef,
192+
serviceRef: v.serviceRef,
193+
slotStart: new Date(v.slotStart).toISOString(),
194+
slotEnd: new Date(v.slotEnd).toISOString(),
195+
metadata: parsedMeta,
196+
},
197+
});
198+
});
143199

144200
const bookings = bookingsQuery.data?.data ?? [];
145201
const total = bookingsQuery.data?.total ?? 0;
@@ -211,7 +267,7 @@ export default function BookingsPage() {
211267
<TableHead>Slot End</TableHead>
212268
<TableHead>Status</TableHead>
213269
<TableHead>Created</TableHead>
214-
<TableHead className="w-24" />
270+
<TableHead className="w-28" />
215271
</TableRow>
216272
</TableHeader>
217273
<TableBody>
@@ -229,6 +285,17 @@ export default function BookingsPage() {
229285
</TableCell>
230286
<TableCell>
231287
<div className="flex items-center gap-1">
288+
{(b.status === "pending" || b.status === "confirmed") && (
289+
<Button
290+
variant="ghost"
291+
size="sm"
292+
className="text-slate-500 hover:bg-slate-100"
293+
title="Edit booking"
294+
onClick={() => openEdit(b)}
295+
>
296+
<Pencil className="h-3.5 w-3.5" />
297+
</Button>
298+
)}
232299
{nextStatuses.length > 0 && (
233300
<Button
234301
variant="ghost"
@@ -280,21 +347,28 @@ export default function BookingsPage() {
280347
</Card>
281348
</div>
282349

283-
{/* Create booking dialog */}
350+
{/* ── Create booking dialog ────────────────────────────────────────────── */}
284351
<Dialog
285352
open={createOpen}
286-
onClose={() => { setCreateOpen(false); reset(); }}
353+
onClose={() => { setCreateOpen(false); createForm.reset(); }}
287354
title="New Booking"
288355
description="Create a booking for this tenant."
289356
>
290357
<form
291-
onSubmit={handleSubmit((v) =>
358+
onSubmit={handleSubmit((v) => {
359+
let meta: Record<string, unknown> = {};
360+
if (v.metadata?.trim()) {
361+
try { meta = JSON.parse(v.metadata); }
362+
catch { createForm.setError("metadata", { message: "Invalid JSON" }); return; }
363+
}
292364
createMutation.mutate({
293-
...v,
294-
slotStart: new Date(v.slotStart).toISOString(),
295-
slotEnd: new Date(v.slotEnd).toISOString(),
296-
})
297-
)}
365+
customerRef: v.customerRef,
366+
serviceRef: v.serviceRef,
367+
slotStart: new Date(v.slotStart).toISOString(),
368+
slotEnd: new Date(v.slotEnd).toISOString(),
369+
metadata: meta,
370+
});
371+
})}
298372
className="space-y-4"
299373
>
300374
<Input
@@ -321,8 +395,22 @@ export default function BookingsPage() {
321395
error={errors.slotEnd?.message}
322396
{...register("slotEnd")}
323397
/>
398+
<div>
399+
<label className="block text-sm font-medium text-slate-700 mb-1">
400+
Metadata <span className="text-slate-400 font-normal">(optional JSON)</span>
401+
</label>
402+
<textarea
403+
rows={3}
404+
placeholder={'{"notes": "first visit"}'}
405+
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
406+
{...register("metadata")}
407+
/>
408+
{errors.metadata && (
409+
<p className="mt-1 text-xs text-red-500">{errors.metadata.message}</p>
410+
)}
411+
</div>
324412
<div className="flex justify-end gap-2 pt-1">
325-
<Button type="button" variant="outline" onClick={() => { setCreateOpen(false); reset(); }}>
413+
<Button type="button" variant="outline" onClick={() => { setCreateOpen(false); createForm.reset(); }}>
326414
Cancel
327415
</Button>
328416
<Button type="submit" loading={createMutation.isPending}>
@@ -332,7 +420,69 @@ export default function BookingsPage() {
332420
</form>
333421
</Dialog>
334422

335-
{/* Status update dialog */}
423+
{/* ── Edit booking dialog ──────────────────────────────────────────────── */}
424+
{editTarget && (
425+
<Dialog
426+
open
427+
onClose={() => setEditTarget(null)}
428+
title="Edit Booking"
429+
description={`Editing booking for "${editTarget.customerRef}"`}
430+
>
431+
<form onSubmit={submitEdit} className="space-y-4">
432+
<Input
433+
label="Customer Reference"
434+
placeholder="cust_12345"
435+
error={editForm.formState.errors.customerRef?.message}
436+
{...editForm.register("customerRef")}
437+
/>
438+
<Input
439+
label="Service Reference"
440+
placeholder="svc_abc"
441+
error={editForm.formState.errors.serviceRef?.message}
442+
{...editForm.register("serviceRef")}
443+
/>
444+
<Input
445+
label="Slot Start"
446+
type="datetime-local"
447+
error={editForm.formState.errors.slotStart?.message}
448+
{...editForm.register("slotStart")}
449+
/>
450+
<Input
451+
label="Slot End"
452+
type="datetime-local"
453+
error={editForm.formState.errors.slotEnd?.message}
454+
{...editForm.register("slotEnd")}
455+
/>
456+
<div>
457+
<label className="block text-sm font-medium text-slate-700 mb-1">
458+
Metadata <span className="text-slate-400 font-normal">(optional JSON)</span>
459+
</label>
460+
<textarea
461+
rows={3}
462+
placeholder={'{"notes": ""}'}
463+
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
464+
{...editForm.register("metadata")}
465+
/>
466+
{editForm.formState.errors.metadata && (
467+
<p className="mt-1 text-xs text-red-500">{editForm.formState.errors.metadata.message}</p>
468+
)}
469+
</div>
470+
<div className="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-xs text-amber-700">
471+
Status <strong>{editTarget.status}</strong> is unchanged. Use the status button to change it.
472+
</div>
473+
<div className="flex justify-end gap-2 pt-1">
474+
<Button type="button" variant="outline" onClick={() => setEditTarget(null)}>
475+
Cancel
476+
</Button>
477+
<Button type="submit" loading={editMutation.isPending}>
478+
Save Changes
479+
</Button>
480+
</div>
481+
</form>
482+
</Dialog>
483+
)}
484+
485+
{/* ── Status update dialog ─────────────────────────────────────────────── */}
336486
{updateTarget && (
337487
<Dialog
338488
open
@@ -359,7 +509,7 @@ export default function BookingsPage() {
359509
</Dialog>
360510
)}
361511

362-
{/* Cancel confirm */}
512+
{/* ── Cancel confirm ───────────────────────────────────────────────────── */}
363513
<ConfirmDialog
364514
open={!!cancelTarget}
365515
onClose={() => setCancelTarget(null)}

apps/management-ui/app/docs/page.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
X,
2727
} from "lucide-react";
2828

29-
// ─── helpers ─────────────────────────────────────────────────────────────────
29+
// ─── helpers ───────────────
3030

3131
function useClipboard(timeout = 1800) {
3232
const [copied, setCopied] = useState<string | null>(null);
@@ -39,7 +39,7 @@ function useClipboard(timeout = 1800) {
3939
return { copied, copy };
4040
}
4141

42-
// ─── components ──────────────────────────────────────────────────────────────
42+
// ─── components ────────────
4343

4444
function CodeBlock({
4545
id,
@@ -195,7 +195,7 @@ function Note({ children, type = "info" }: { children: React.ReactNode; type?: "
195195
);
196196
}
197197

198-
// ─── nav config ──────────────────────────────────────────────────────────────
198+
// ─── nav config ────────────
199199

200200
const NAV = [
201201
{ id: "overview", label: "Overview", icon: BookOpen },
@@ -212,7 +212,7 @@ const NAV = [
212212
{ id: "docker", label: "Self-hosting", icon: Terminal },
213213
];
214214

215-
// ─── page ─────────────────────────────────────────────────────────────────────
215+
// ─── page ───────────────────
216216

217217
export default function DocsPage() {
218218
const { copied, copy } = useClipboard();

0 commit comments

Comments
 (0)