@@ -30,10 +30,14 @@ import { PageSpinner } from "@/components/ui/spinner";
3030import { useToast } from "@/components/ui/toast" ;
3131import { tenantsApi } from "@/lib/api/tenants" ;
3232import {
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" ;
3539import { formatDate } from "@/lib/utils" ;
36- import { CalendarDays , Plus , Trash2 , RefreshCw } from "lucide-react" ;
40+ import { CalendarDays , Plus , Trash2 , RefreshCw , Pencil } from "lucide-react" ;
3741
3842const 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
5459const 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 ) }
0 commit comments