@fayz-ai/plugin-agenda 0.1.8 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-GAJVIEXM.cjs +870 -0
- package/dist/chunk-GAJVIEXM.cjs.map +1 -0
- package/dist/chunk-U6VTLOHN.js +867 -0
- package/dist/chunk-U6VTLOHN.js.map +1 -0
- package/dist/components/AgendaMobileList.d.ts +10 -0
- package/dist/components/AgendaMobileList.d.ts.map +1 -0
- package/dist/components/AppointmentModal.d.ts +4 -2
- package/dist/components/AppointmentModal.d.ts.map +1 -1
- package/dist/components/AppointmentPopover.d.ts.map +1 -1
- package/dist/components/EventContextMenu.d.ts.map +1 -1
- package/dist/config.d.ts +62 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/mock.d.ts +15 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.cjs +611 -1157
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +445 -994
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/public/BookingWidget.d.ts +9 -0
- package/dist/public/BookingWidget.d.ts.map +1 -0
- package/dist/public/context.d.ts +37 -0
- package/dist/public/context.d.ts.map +1 -0
- package/dist/public/createPublicBookingPlugin.d.ts +45 -0
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -0
- package/dist/public/hooks.d.ts +32 -0
- package/dist/public/hooks.d.ts.map +1 -0
- package/dist/public/index.cjs +1020 -0
- package/dist/public/index.cjs.map +1 -0
- package/dist/public/index.d.ts +9 -0
- package/dist/public/index.d.ts.map +1 -0
- package/dist/public/index.js +1012 -0
- package/dist/public/index.js.map +1 -0
- package/dist/public/types.d.ts +51 -0
- package/dist/public/types.d.ts.map +1 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/CalendarView.d.ts.map +1 -1
- package/package.json +11 -8
- package/src/components/AgendaMobileList.tsx +103 -0
- package/src/components/AppointmentModal.tsx +86 -9
- package/src/components/AppointmentPopover.tsx +20 -10
- package/src/components/EventContextMenu.tsx +3 -0
- package/src/config.ts +104 -5
- package/src/data/index.ts +1 -0
- package/src/data/mock.ts +26 -6
- package/src/data/supabase.ts +13 -2
- package/src/index.ts +11 -3
- package/src/integrations/google-calendar/integration.test.ts +1 -1
- package/src/locales/en.ts +12 -0
- package/src/locales/pt-BR.ts +12 -0
- package/src/public/BookingWidget.tsx +892 -0
- package/src/public/context.tsx +43 -0
- package/src/public/createPublicBookingPlugin.ts +155 -0
- package/src/public/hooks.ts +117 -0
- package/src/public/index.ts +15 -0
- package/src/public/types.ts +63 -0
- package/src/store.ts +4 -2
- package/src/types.ts +8 -0
- package/src/views/CalendarView.tsx +220 -54
|
@@ -21,6 +21,7 @@ import { useTranslation } from '@fayz-ai/core'
|
|
|
21
21
|
import { getCurrentLocale } from '@fayz-ai/core'
|
|
22
22
|
import { isStatusAvailable } from '../types'
|
|
23
23
|
import type { CreateBookingInput, BookingTypeId } from '../types'
|
|
24
|
+
import type { ExternalBookingSource } from '../config'
|
|
24
25
|
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
// Helpers
|
|
@@ -38,6 +39,7 @@ function formatCompactDate(dateStr: string, locale: string): string {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
interface ServiceItem { serviceId: string; name: string; durationMinutes: number; price: number }
|
|
42
|
+
interface CancellationReasonItem { id: string; label: string; requiresNotes?: boolean }
|
|
41
43
|
|
|
42
44
|
// ---------------------------------------------------------------------------
|
|
43
45
|
// Inline editable number — allows clearing, commits on blur/enter
|
|
@@ -266,11 +268,12 @@ interface Props {
|
|
|
266
268
|
bookingId?: string
|
|
267
269
|
prefill?: Partial<CreateBookingInput> & { endsAt?: string }
|
|
268
270
|
initialTab?: string
|
|
271
|
+
externalSource?: ExternalBookingSource
|
|
269
272
|
onClose: () => void
|
|
270
|
-
onCreated?: (bookingId: string) => void
|
|
273
|
+
onCreated?: (bookingId: string, source?: ExternalBookingSource) => void
|
|
271
274
|
}
|
|
272
275
|
|
|
273
|
-
export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, onClose, onCreated }: Props) {
|
|
276
|
+
export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, externalSource, onClose, onCreated }: Props) {
|
|
274
277
|
const t = useTranslation()
|
|
275
278
|
const locale = getCurrentLocale()
|
|
276
279
|
const config = useAgendaConfig()
|
|
@@ -285,8 +288,12 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
|
|
|
285
288
|
const storeLocations = useAgendaStore((s) => s.locations)
|
|
286
289
|
|
|
287
290
|
// --- State ---
|
|
291
|
+
// Default to the first configured booking type (e.g. 'event' in simple mode,
|
|
292
|
+
// 'appointment' otherwise) so single-type agendas resolve their type cleanly.
|
|
293
|
+
const defaultBookingType = (config.bookingTypes[0]?.id ?? 'appointment') as BookingTypeId
|
|
288
294
|
const [loading, setLoading] = useState(mode === 'edit')
|
|
289
|
-
const [bookingType, setBookingType] = useState<BookingTypeId>(prefill?.kind ??
|
|
295
|
+
const [bookingType, setBookingType] = useState<BookingTypeId>(prefill?.kind ?? defaultBookingType)
|
|
296
|
+
const [title, setTitle] = useState('')
|
|
290
297
|
const [clientId, setClientId] = useState(prefill?.clientId ?? '')
|
|
291
298
|
const [clientSearch, setClientSearch] = useState('')
|
|
292
299
|
const [clientPhone, setClientPhone] = useState('')
|
|
@@ -331,6 +338,10 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
|
|
|
331
338
|
const [clientData, setClientData] = useState<Record<string, any> | null>(null)
|
|
332
339
|
const [clientDataLoading, setClientDataLoading] = useState(false)
|
|
333
340
|
const [clientEditing, setClientEditing] = useState(false)
|
|
341
|
+
const [cancellationReasons, setCancellationReasons] = useState<CancellationReasonItem[]>([])
|
|
342
|
+
const [cancellationReasonId, setCancellationReasonId] = useState('')
|
|
343
|
+
const [cancellationNotes, setCancellationNotes] = useState('')
|
|
344
|
+
const [cancellationReasonsLoading, setCancellationReasonsLoading] = useState(false)
|
|
334
345
|
|
|
335
346
|
// Active booking type config
|
|
336
347
|
const activeType = config.bookingTypes.find((bt) => bt.id === bookingType) ?? config.bookingTypes[0]
|
|
@@ -343,10 +354,11 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
|
|
|
343
354
|
setServiceSearch(''); setNotes(''); setStatus('scheduled'); setShowNotes(false)
|
|
344
355
|
setConflict(false); setLoading(mode === 'edit')
|
|
345
356
|
setQuickCreate(false); setNewClientName(''); setNewClientPhone(''); setNewClientEmail(''); setCreatingClient(false)
|
|
346
|
-
setBookingType(prefill?.kind ?? '
|
|
357
|
+
setBookingType(prefill?.kind ?? defaultBookingType); setTitle('')
|
|
347
358
|
setEditingDate(false); setEditingTime(false); setConfirmingDelete(false); setDeleting(false)
|
|
348
359
|
setEditOrderId(null); setEditOrderTotal(0); setEditPaymentStatus('none'); setPaymentStatusLoading(false)
|
|
349
360
|
setClientData(null); setClientDataLoading(false); setClientEditing(false)
|
|
361
|
+
setCancellationReasonId(''); setCancellationNotes(''); setCancellationReasonsLoading(false)
|
|
350
362
|
setModalTab((initialTab as 'appointment' | 'client' | 'financial') || 'appointment')
|
|
351
363
|
if (!prefill?.professionalId && professionals.length === 1) setProfessionalId(professionals[0].id)
|
|
352
364
|
setDate(() => {
|
|
@@ -368,7 +380,8 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
|
|
|
368
380
|
setLoading(true)
|
|
369
381
|
provider.getBookingById(bookingId).then((b) => {
|
|
370
382
|
if (!b) { setLoading(false); return }
|
|
371
|
-
setBookingType((b.kind as BookingTypeId) ??
|
|
383
|
+
setBookingType((b.kind as BookingTypeId) ?? defaultBookingType)
|
|
384
|
+
setTitle(b.clientName ?? '')
|
|
372
385
|
setClientId(b.clientId ?? ''); setClientSearch(b.clientName ?? '')
|
|
373
386
|
setProfessionalId(b.professionalId ?? ''); setLocationId(b.locationId ?? '')
|
|
374
387
|
setDate(b.startsAt.slice(0, 10))
|
|
@@ -385,10 +398,11 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
|
|
|
385
398
|
// Save initial snapshot for dirty check
|
|
386
399
|
const svcSnap = b.services ? b.services.map((s: any) => ({ id: s.serviceId ?? '', dur: s.durationMinutes, price: s.price })) : []
|
|
387
400
|
initialSnapshot.current = JSON.stringify({
|
|
401
|
+
title: b.clientName ?? '',
|
|
388
402
|
clientId: b.clientId ?? '', professionalId: b.professionalId ?? '', locationId: b.locationId ?? '',
|
|
389
403
|
date: b.startsAt.slice(0, 10),
|
|
390
404
|
startTime: `${String(new Date(b.startsAt).getHours()).padStart(2, '0')}:${String(new Date(b.startsAt).getMinutes()).padStart(2, '0')}`,
|
|
391
|
-
status: b.status, notes: b.notes ?? '', services: svcSnap,
|
|
405
|
+
status: b.status, notes: b.notes ?? '', cancellationReasonId: '', cancellationNotes: '', services: svcSnap,
|
|
392
406
|
})
|
|
393
407
|
setLoading(false)
|
|
394
408
|
// Resolve payment status from bridge if available
|
|
@@ -415,6 +429,15 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
|
|
|
415
429
|
}).catch(() => setClientDataLoading(false))
|
|
416
430
|
}, [modalTab, clientId])
|
|
417
431
|
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
if (!open || status !== 'cancelled' || !config.cancellationDetails || cancellationReasons.length > 0) return
|
|
434
|
+
setCancellationReasonsLoading(true)
|
|
435
|
+
config.cancellationDetails.listReasons()
|
|
436
|
+
.then((reasons) => setCancellationReasons(reasons))
|
|
437
|
+
.catch(() => setCancellationReasons([]))
|
|
438
|
+
.finally(() => setCancellationReasonsLoading(false))
|
|
439
|
+
}, [open, status, config.cancellationDetails, cancellationReasons.length])
|
|
440
|
+
|
|
418
441
|
// Fetch initial service list when modal opens
|
|
419
442
|
useEffect(() => {
|
|
420
443
|
if (!open || !config.serviceLookup) return
|
|
@@ -497,14 +520,24 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
|
|
|
497
520
|
const startsAt = new Date(`${date}T${startTime}:00`).toISOString()
|
|
498
521
|
if (mode === 'create') {
|
|
499
522
|
const newBooking = await createBooking({ kind: bookingType, clientId, professionalId, locationId: locationId || undefined, startsAt, notes: notes || undefined,
|
|
523
|
+
title: activeType.fields.title ? title.trim() : undefined,
|
|
500
524
|
services: services.map((s: any) => ({ serviceId: s.serviceId, name: s.name, durationMinutes: s.durationMinutes, price: s.price })) })
|
|
501
525
|
onClose()
|
|
502
|
-
onCreated?.(newBooking.id)
|
|
526
|
+
onCreated?.(newBooking.id, externalSource)
|
|
503
527
|
} else if (bookingId) {
|
|
504
528
|
await updateBooking(bookingId, {
|
|
505
529
|
clientId, professionalId, locationId: locationId || undefined, startsAt, notes: notes || undefined, status: status as any,
|
|
530
|
+
title: activeType.fields.title ? title.trim() : undefined,
|
|
506
531
|
services: services.map((s: any) => ({ serviceId: s.serviceId, name: s.name, durationMinutes: s.durationMinutes, price: s.price })),
|
|
507
532
|
})
|
|
533
|
+
if (status === 'cancelled' && config.cancellationDetails && cancellationReasonId) {
|
|
534
|
+
await config.cancellationDetails.saveCancellationDetails({
|
|
535
|
+
bookingId,
|
|
536
|
+
reasonId: cancellationReasonId,
|
|
537
|
+
notes: cancellationNotes.trim() || undefined,
|
|
538
|
+
cancelledAt: new Date().toISOString(),
|
|
539
|
+
})
|
|
540
|
+
}
|
|
508
541
|
onClose()
|
|
509
542
|
}
|
|
510
543
|
} catch { /* toast */ }
|
|
@@ -519,18 +552,24 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
|
|
|
519
552
|
}
|
|
520
553
|
|
|
521
554
|
const isPaidBooking = editPaymentStatus === 'paid'
|
|
555
|
+
const selectedCancellationReason = cancellationReasons.find((reason) => reason.id === cancellationReasonId)
|
|
556
|
+
const needsCancellationReason = mode === 'edit' && status === 'cancelled' && !!config.cancellationDetails
|
|
557
|
+
const needsCancellationNotes = needsCancellationReason && !!selectedCancellationReason?.requiresNotes
|
|
522
558
|
|
|
523
559
|
// Dirty check — only enable Update when something changed
|
|
524
560
|
const formSnapshot = useMemo(() =>
|
|
525
|
-
JSON.stringify({ clientId, professionalId, locationId, date, startTime, status, notes, services: services.map((s: any) => ({ id: s.serviceId, dur: s.durationMinutes, price: s.price })) }),
|
|
526
|
-
[clientId, professionalId, locationId, date, startTime, status, notes, services]
|
|
561
|
+
JSON.stringify({ title, clientId, professionalId, locationId, date, startTime, status, notes, cancellationReasonId, cancellationNotes, services: services.map((s: any) => ({ id: s.serviceId, dur: s.durationMinutes, price: s.price })) }),
|
|
562
|
+
[title, clientId, professionalId, locationId, date, startTime, status, notes, cancellationReasonId, cancellationNotes, services]
|
|
527
563
|
)
|
|
528
564
|
const isDirty = mode === 'create' || formSnapshot !== initialSnapshot.current
|
|
529
565
|
|
|
530
566
|
const canSubmit = (
|
|
567
|
+
(!activeType.fields.title || title.trim()) &&
|
|
531
568
|
(!activeType.requiresClient || clientId) &&
|
|
532
569
|
(!activeType.fields.professional || professionalId) &&
|
|
533
570
|
(!activeType.requiresServices || services.length > 0) &&
|
|
571
|
+
(!needsCancellationReason || !!cancellationReasonId) &&
|
|
572
|
+
(!needsCancellationNotes || !!cancellationNotes.trim()) &&
|
|
534
573
|
!conflict && !saving
|
|
535
574
|
)
|
|
536
575
|
|
|
@@ -748,6 +787,16 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
|
|
|
748
787
|
|
|
749
788
|
{!loading && <>
|
|
750
789
|
|
|
790
|
+
{/* Title — free-text event name (simple / Google-Calendar-style events) */}
|
|
791
|
+
{activeType.fields.title && (
|
|
792
|
+
<div className="flex items-center gap-3 py-1">
|
|
793
|
+
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
794
|
+
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)}
|
|
795
|
+
placeholder={t('agenda.appointment.addTitle')} autoFocus={mode === 'create'}
|
|
796
|
+
className="w-full flex-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2.5 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
797
|
+
</div>
|
|
798
|
+
)}
|
|
799
|
+
|
|
751
800
|
{/* Client */}
|
|
752
801
|
{activeType.fields.client && (
|
|
753
802
|
<div className="flex items-center gap-3 py-1">
|
|
@@ -927,6 +976,34 @@ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, o
|
|
|
927
976
|
</div>
|
|
928
977
|
)}
|
|
929
978
|
|
|
979
|
+
{needsCancellationReason && (
|
|
980
|
+
<div className="flex items-start gap-3 py-1">
|
|
981
|
+
<Ban className="h-4 w-4 mt-2 text-muted-foreground shrink-0" />
|
|
982
|
+
<div className="flex-1 space-y-2">
|
|
983
|
+
<select
|
|
984
|
+
value={cancellationReasonId}
|
|
985
|
+
onChange={(event) => setCancellationReasonId(event.target.value)}
|
|
986
|
+
disabled={cancellationReasonsLoading}
|
|
987
|
+
className="w-full rounded-input border border-input bg-card px-2.5 py-1.5 text-sm shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
988
|
+
>
|
|
989
|
+
<option value="">{cancellationReasonsLoading ? t('agenda.cancellation.loadingReasons') : t('agenda.cancellation.selectReason')}</option>
|
|
990
|
+
{cancellationReasons.map((reason) => (
|
|
991
|
+
<option key={reason.id} value={reason.id}>
|
|
992
|
+
{reason.label}{reason.requiresNotes ? ' *' : ''}
|
|
993
|
+
</option>
|
|
994
|
+
))}
|
|
995
|
+
</select>
|
|
996
|
+
<textarea
|
|
997
|
+
value={cancellationNotes}
|
|
998
|
+
onChange={(event) => setCancellationNotes(event.target.value)}
|
|
999
|
+
placeholder={needsCancellationNotes ? t('agenda.cancellation.notesRequired') : t('agenda.cancellation.notesOptional')}
|
|
1000
|
+
rows={2}
|
|
1001
|
+
className="w-full rounded-input border border-input bg-card px-2.5 py-1.5 text-sm resize-none shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
1002
|
+
/>
|
|
1003
|
+
</div>
|
|
1004
|
+
</div>
|
|
1005
|
+
)}
|
|
1006
|
+
|
|
930
1007
|
{/* Notes — progressive disclosure */}
|
|
931
1008
|
{!showNotes ? (
|
|
932
1009
|
<div className="flex items-center gap-3 py-1">
|
|
@@ -126,7 +126,14 @@ export function AppointmentPopover({ booking, position, onClose, onEdit }: Props
|
|
|
126
126
|
<div className="flex items-start gap-3">
|
|
127
127
|
<span className="mt-1.5 h-3 w-3 rounded shrink-0" style={{ backgroundColor: color }} />
|
|
128
128
|
<div className="min-w-0">
|
|
129
|
-
|
|
129
|
+
{/* Client agendas link to the person; simple events (no client
|
|
130
|
+
capability) render the free-text title as a plain heading —
|
|
131
|
+
booking.clientName carries the event title in that model. */}
|
|
132
|
+
{config.capabilities.client ? (
|
|
133
|
+
<PersonLink personId={booking.clientId} name={booking.clientName ?? 'Unknown'} className="text-base" />
|
|
134
|
+
) : (
|
|
135
|
+
<p className="text-base font-medium truncate">{booking.clientName || t('agenda.appointment.untitled')}</p>
|
|
136
|
+
)}
|
|
130
137
|
<p className="text-sm text-muted-foreground mt-0.5">{fmtDate(booking.startsAt)} · {timeRange}</p>
|
|
131
138
|
</div>
|
|
132
139
|
</div>
|
|
@@ -142,7 +149,7 @@ export function AppointmentPopover({ booking, position, onClose, onEdit }: Props
|
|
|
142
149
|
</div>
|
|
143
150
|
)}
|
|
144
151
|
|
|
145
|
-
{booking.professionalName && (
|
|
152
|
+
{config.capabilities.professional && booking.professionalName && (
|
|
146
153
|
<div className="flex items-center gap-3">
|
|
147
154
|
<User className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
148
155
|
<span className="text-sm">{booking.professionalName}</span>
|
|
@@ -204,14 +211,17 @@ export function AppointmentPopover({ booking, position, onClose, onEdit }: Props
|
|
|
204
211
|
)
|
|
205
212
|
})()}
|
|
206
213
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
{/* Status workflow — hidden for simple events (no status capability). */}
|
|
215
|
+
{config.capabilities.status && (
|
|
216
|
+
<div className="pt-2 border-t fayz-glass-divider">
|
|
217
|
+
<PopoverStatusSelect
|
|
218
|
+
value={booking.status}
|
|
219
|
+
statuses={config.statuses}
|
|
220
|
+
bookingStartsAt={booking.startsAt}
|
|
221
|
+
onChange={(v) => { updateStatus(booking.id, v); onClose() }}
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
215
225
|
</div>
|
|
216
226
|
</>
|
|
217
227
|
)}
|
|
@@ -101,6 +101,8 @@ export function EventContextMenu({ booking, position, onClose, onEdit }: Props)
|
|
|
101
101
|
)}
|
|
102
102
|
</div>
|
|
103
103
|
|
|
104
|
+
{/* Status workflow — hidden for simple events (no status capability). */}
|
|
105
|
+
{config.capabilities.status && (
|
|
104
106
|
<div className="border-t px-4 py-3">
|
|
105
107
|
<p className="text-[11px] font-medium text-muted-foreground mb-2">{t('agenda.contextMenu.changeStatus')}</p>
|
|
106
108
|
<div className="flex items-center gap-1.5">
|
|
@@ -115,6 +117,7 @@ export function EventContextMenu({ booking, position, onClose, onEdit }: Props)
|
|
|
115
117
|
})}
|
|
116
118
|
</div>
|
|
117
119
|
</div>
|
|
120
|
+
)}
|
|
118
121
|
</>
|
|
119
122
|
)}
|
|
120
123
|
</FloatingPanel>
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PluginScope, VerticalId } from '@fayz-ai/core'
|
|
1
|
+
import type { PluginRegistryDef, PluginScope, VerticalId } from '@fayz-ai/core'
|
|
2
2
|
import type { EntityLookup } from '@fayz-ai/saas'
|
|
3
3
|
import type { AgendaDataProvider } from './data/types'
|
|
4
4
|
import type { StatusConfig, BookingTypeConfig } from './types'
|
|
@@ -29,6 +29,28 @@ export interface ConfirmationChannelOption {
|
|
|
29
29
|
icon: string
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export interface CancellationReasonOption {
|
|
33
|
+
id: string
|
|
34
|
+
label: string
|
|
35
|
+
requiresNotes?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CancellationDetailsProvider {
|
|
39
|
+
listReasons(): Promise<CancellationReasonOption[]>
|
|
40
|
+
saveCancellationDetails(input: {
|
|
41
|
+
bookingId: string
|
|
42
|
+
reasonId: string
|
|
43
|
+
notes?: string
|
|
44
|
+
cancelledAt?: string
|
|
45
|
+
}): Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ExternalBookingSource {
|
|
49
|
+
type: string
|
|
50
|
+
id?: string
|
|
51
|
+
[key: string]: unknown
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
export interface LocationOption {
|
|
33
55
|
id: string
|
|
34
56
|
name: string
|
|
@@ -60,6 +82,14 @@ export interface AgendaPluginOptions {
|
|
|
60
82
|
/** Booking type tabs shown in the appointment modal */
|
|
61
83
|
bookingTypes?: BookingTypeConfig[]
|
|
62
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Simplifies the agenda to Google-Calendar-style events (no client /
|
|
87
|
+
* professional / service). When set to 'simple', the plugin exposes a single
|
|
88
|
+
* "Evento" booking type whose modal shows only title / date-time / notes.
|
|
89
|
+
* Ignored when an explicit `bookingTypes` override is provided.
|
|
90
|
+
*/
|
|
91
|
+
eventMode?: 'simple'
|
|
92
|
+
|
|
63
93
|
/** Booking status options */
|
|
64
94
|
statuses?: StatusConfig[]
|
|
65
95
|
|
|
@@ -105,6 +135,15 @@ export interface AgendaPluginOptions {
|
|
|
105
135
|
/** Confirmation channel options */
|
|
106
136
|
confirmationChannels?: ConfirmationChannelOption[]
|
|
107
137
|
|
|
138
|
+
/** Optional app/plugin bridge for cancel-time reason capture. */
|
|
139
|
+
cancellationDetails?: CancellationDetailsProvider
|
|
140
|
+
|
|
141
|
+
/** Called after a booking is created from an external operational source. */
|
|
142
|
+
onBookingCreated?: (input: { bookingId: string; source?: ExternalBookingSource }) => void | Promise<void>
|
|
143
|
+
|
|
144
|
+
/** App-owned registries shown under /settings/agenda Properties. */
|
|
145
|
+
settingsRegistries?: PluginRegistryDef[]
|
|
146
|
+
|
|
108
147
|
/** Financial bridge for agenda × financial cross-plugin integration */
|
|
109
148
|
financialBridge?: AgendaFinancialBridge
|
|
110
149
|
|
|
@@ -172,6 +211,17 @@ const DEFAULT_BOOKING_TYPES: BookingTypeConfig[] = [
|
|
|
172
211
|
},
|
|
173
212
|
]
|
|
174
213
|
|
|
214
|
+
// Simple / Google-Calendar-style event — a single "Evento" type with just a
|
|
215
|
+
// free-text title, date-time and notes. No client / professional / service.
|
|
216
|
+
// Used by personal-finance-style agendas (see eventMode: 'simple').
|
|
217
|
+
const SIMPLE_EVENT_BOOKING_TYPES: BookingTypeConfig[] = [
|
|
218
|
+
{
|
|
219
|
+
id: 'event', label: 'Event', icon: 'Calendar', color: '#6366f1',
|
|
220
|
+
fields: { title: true, client: false, professional: false, services: false, location: false, status: false },
|
|
221
|
+
requiresServices: false, requiresClient: false,
|
|
222
|
+
},
|
|
223
|
+
]
|
|
224
|
+
|
|
175
225
|
// ---------------------------------------------------------------------------
|
|
176
226
|
// Resolved config — fully merged, no optionals
|
|
177
227
|
// ---------------------------------------------------------------------------
|
|
@@ -185,6 +235,24 @@ export interface AgendaModules {
|
|
|
185
235
|
financial: boolean
|
|
186
236
|
}
|
|
187
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Derived UI capabilities — computed ONCE in resolveConfig from the resolved
|
|
240
|
+
* bookingTypes so no component ever re-derives "is this a simple event agenda?"
|
|
241
|
+
* ad-hoc. A capability is enabled when ANY booking type uses that field. In
|
|
242
|
+
* eventMode:'simple' the single "Evento" type disables
|
|
243
|
+
* client/professional/services/location/status, so every consumer (popover,
|
|
244
|
+
* filter rail, modal) hides the professional/service/status machinery in one
|
|
245
|
+
* place instead of scattering `eventMode === 'simple'` checks.
|
|
246
|
+
*/
|
|
247
|
+
export interface AgendaCapabilities {
|
|
248
|
+
title: boolean
|
|
249
|
+
client: boolean
|
|
250
|
+
professional: boolean
|
|
251
|
+
services: boolean
|
|
252
|
+
location: boolean
|
|
253
|
+
status: boolean
|
|
254
|
+
}
|
|
255
|
+
|
|
188
256
|
export interface AgendaCurrency {
|
|
189
257
|
code: string
|
|
190
258
|
locale: string
|
|
@@ -193,6 +261,10 @@ export interface AgendaCurrency {
|
|
|
193
261
|
|
|
194
262
|
export interface ResolvedAgendaConfig {
|
|
195
263
|
modules: AgendaModules
|
|
264
|
+
/** Whether this is a simple Google-Calendar-style event agenda (eventMode:'simple'). */
|
|
265
|
+
simpleMode: boolean
|
|
266
|
+
/** Derived once from bookingTypes — see AgendaCapabilities. */
|
|
267
|
+
capabilities: AgendaCapabilities
|
|
196
268
|
labels: AgendaPluginLabels
|
|
197
269
|
currency: AgendaCurrency
|
|
198
270
|
bookingKind: string
|
|
@@ -207,6 +279,8 @@ export interface ResolvedAgendaConfig {
|
|
|
207
279
|
autoCreateOrder: boolean
|
|
208
280
|
locations: LocationOption[]
|
|
209
281
|
confirmationChannels: ConfirmationChannelOption[]
|
|
282
|
+
cancellationDetails?: CancellationDetailsProvider
|
|
283
|
+
onBookingCreated?: (input: { bookingId: string; source?: ExternalBookingSource }) => void | Promise<void>
|
|
210
284
|
contactLookup?: EntityLookup
|
|
211
285
|
clientEntityDef?: EntityDef
|
|
212
286
|
serviceLookup?: EntityLookup
|
|
@@ -228,22 +302,45 @@ export interface ResolvedAgendaConfig {
|
|
|
228
302
|
// ---------------------------------------------------------------------------
|
|
229
303
|
|
|
230
304
|
export function resolveConfig(options?: AgendaPluginOptions): ResolvedAgendaConfig {
|
|
305
|
+
// ── eventMode:'simple' derives ALL module + capability defaults ONCE, here. ──
|
|
306
|
+
// Simple = Google-Calendar-style events (title/description + timestamp). It
|
|
307
|
+
// flips professional/service machinery OFF by default: no working-hours /
|
|
308
|
+
// schedule checks, no confirmations outreach, no conflict (double-booking)
|
|
309
|
+
// detection, no location. Explicit `modules.*` re-enable individually. Full
|
|
310
|
+
// mode (beauty) is unchanged — every default below matches the old behavior
|
|
311
|
+
// when `simple` is false. Field-level machinery (client/professional/
|
|
312
|
+
// services/location/status/title) is derived into `capabilities` from the
|
|
313
|
+
// resolved bookingTypes, so components never test eventMode themselves.
|
|
314
|
+
const simple = options?.eventMode === 'simple'
|
|
315
|
+
const bookingTypes = options?.bookingTypes
|
|
316
|
+
?? (simple ? SIMPLE_EVENT_BOOKING_TYPES : DEFAULT_BOOKING_TYPES)
|
|
317
|
+
const capabilities: AgendaCapabilities = {
|
|
318
|
+
title: bookingTypes.some((bt) => bt.fields.title === true),
|
|
319
|
+
client: bookingTypes.some((bt) => bt.fields.client),
|
|
320
|
+
professional: bookingTypes.some((bt) => bt.fields.professional),
|
|
321
|
+
services: bookingTypes.some((bt) => bt.fields.services),
|
|
322
|
+
location: bookingTypes.some((bt) => bt.fields.location),
|
|
323
|
+
status: bookingTypes.some((bt) => bt.fields.status),
|
|
324
|
+
}
|
|
231
325
|
return {
|
|
232
326
|
modules: {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
327
|
+
// Off-unless-explicit in simple mode (`?? !simple`); default-on in full.
|
|
328
|
+
workingHours: options?.modules?.workingHours ?? !simple,
|
|
329
|
+
confirmations: options?.modules?.confirmations ?? !simple,
|
|
330
|
+
conflictDetection: options?.modules?.conflictDetection ?? !simple,
|
|
236
331
|
dragAndDrop: options?.modules?.dragAndDrop !== false,
|
|
237
332
|
locationSelection: options?.modules?.locationSelection === true,
|
|
238
333
|
financial: options?.modules?.financial ?? !!options?.financialBridge,
|
|
239
334
|
},
|
|
335
|
+
simpleMode: simple,
|
|
336
|
+
capabilities,
|
|
240
337
|
labels: { ...DEFAULT_LABELS, ...options?.labels },
|
|
241
338
|
currency: { ...DEFAULT_CURRENCY, ...options?.currency },
|
|
242
339
|
bookingKind: options?.bookingKind ?? 'appointment',
|
|
243
340
|
orderKind: options?.orderKind ?? 'service_order',
|
|
244
341
|
scheduleKind: options?.scheduleKind ?? 'working_hours',
|
|
245
342
|
statuses: options?.statuses ?? DEFAULT_STATUSES,
|
|
246
|
-
bookingTypes
|
|
343
|
+
bookingTypes,
|
|
247
344
|
businessHours: options?.businessHours ?? { startTime: '07:00', endTime: '21:00' },
|
|
248
345
|
slotDuration: options?.slotDuration ?? 30,
|
|
249
346
|
professionalKind: options?.professionalKind ?? 'staff',
|
|
@@ -251,6 +348,8 @@ export function resolveConfig(options?: AgendaPluginOptions): ResolvedAgendaConf
|
|
|
251
348
|
autoCreateOrder: options?.autoCreateOrder !== false,
|
|
252
349
|
locations: options?.locations ?? [],
|
|
253
350
|
confirmationChannels: options?.confirmationChannels ?? [],
|
|
351
|
+
cancellationDetails: options?.cancellationDetails,
|
|
352
|
+
onBookingCreated: options?.onBookingCreated,
|
|
254
353
|
contactLookup: options?.contactLookup,
|
|
255
354
|
clientEntityDef: options?.clientEntityDef,
|
|
256
355
|
serviceLookup: options?.serviceLookup,
|
package/src/data/index.ts
CHANGED
package/src/data/mock.ts
CHANGED
|
@@ -133,10 +133,26 @@ function createSampleSchedules(): Schedule[] {
|
|
|
133
133
|
// Mock provider factory
|
|
134
134
|
// ---------------------------------------------------------------------------
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Pre-populated agenda data. Any omitted collection falls back to the built-in
|
|
138
|
+
* salon-style sample. Pass `bookings` (and optionally an empty `professionals`)
|
|
139
|
+
* to render a calendar seeded with app-specific events.
|
|
140
|
+
*/
|
|
141
|
+
export interface MockAgendaSeed {
|
|
142
|
+
bookings?: CalendarBooking[]
|
|
143
|
+
professionals?: Professional[]
|
|
144
|
+
schedules?: Schedule[]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface MockAgendaProviderOptions {
|
|
148
|
+
seed?: MockAgendaSeed
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createMockAgendaProvider(options?: MockAgendaProviderOptions): AgendaDataProvider {
|
|
152
|
+
const seed = options?.seed
|
|
153
|
+
const bookings: CalendarBooking[] = seed?.bookings ? [...seed.bookings] : createSampleBookings()
|
|
154
|
+
const schedules: Schedule[] = seed?.schedules ? [...seed.schedules] : createSampleSchedules()
|
|
155
|
+
const professionals: Professional[] = seed?.professionals ? [...seed.professionals] : [...SAMPLE_PROFESSIONALS]
|
|
140
156
|
|
|
141
157
|
return {
|
|
142
158
|
async getLocations(): Promise<Array<{ id: string; name: string }>> {
|
|
@@ -167,7 +183,7 @@ export function createMockAgendaProvider(): AgendaDataProvider {
|
|
|
167
183
|
startsAt: input.startsAt, endsAt: addMinutes(input.startsAt, totalDuration || 30),
|
|
168
184
|
status: 'scheduled', notes: input.notes ?? null,
|
|
169
185
|
orderId: input.services.length > 0 ? uid() : null, locationId: input.locationId ?? null, metadata: {},
|
|
170
|
-
clientId: input.clientId, clientName: 'New Client', clientPhone: null, clientEmail: null, clientAvatarUrl: null,
|
|
186
|
+
clientId: input.clientId, clientName: input.title?.trim() || 'New Client', clientPhone: null, clientEmail: null, clientAvatarUrl: null,
|
|
171
187
|
professionalId: input.professionalId,
|
|
172
188
|
professionalName: professionals.find((p) => p.id === input.professionalId)?.name ?? 'Unknown',
|
|
173
189
|
professionalAvatarUrl: null,
|
|
@@ -201,6 +217,7 @@ export function createMockAgendaProvider(): AgendaDataProvider {
|
|
|
201
217
|
...existing,
|
|
202
218
|
...(data.startsAt && { startsAt: data.startsAt }),
|
|
203
219
|
...(data.endsAt && { endsAt: data.endsAt }),
|
|
220
|
+
...(data.title !== undefined && { clientName: data.title }),
|
|
204
221
|
...(data.status && { status: data.status }),
|
|
205
222
|
...(data.notes !== undefined && { notes: data.notes ?? null }),
|
|
206
223
|
...(data.professionalId && { professionalId: data.professionalId }),
|
|
@@ -235,7 +252,10 @@ export function createMockAgendaProvider(): AgendaDataProvider {
|
|
|
235
252
|
},
|
|
236
253
|
|
|
237
254
|
async getAvailableSlots(params: AvailableSlotsParams): Promise<TimeSlot[]> {
|
|
238
|
-
|
|
255
|
+
// Parse 'YYYY-MM-DD' as LOCAL midnight (not UTC) so the weekday and the
|
|
256
|
+
// generated slot instants land on the requested calendar day in any tz.
|
|
257
|
+
const [yy, mm, dd] = params.date.split('-').map(Number)
|
|
258
|
+
const date = new Date(yy, (mm ?? 1) - 1, dd ?? 1)
|
|
239
259
|
const dayOfWeek = date.getDay()
|
|
240
260
|
const interval = params.slotInterval ?? 30
|
|
241
261
|
|
package/src/data/supabase.ts
CHANGED
|
@@ -150,7 +150,8 @@ async function hydrateCanonicalBookings(core: any, bookings: any[]): Promise<Cal
|
|
|
150
150
|
locationId: booking.location_id,
|
|
151
151
|
metadata: booking.metadata ?? {},
|
|
152
152
|
clientId: booking.party_id,
|
|
153
|
-
|
|
153
|
+
// Simple events (no party) fall back to the title stored in metadata.
|
|
154
|
+
clientName: client?.name ?? (booking.metadata?.title as string | undefined) ?? null,
|
|
154
155
|
clientPhone: client?.phone ?? null,
|
|
155
156
|
clientEmail: client?.email ?? null,
|
|
156
157
|
clientAvatarUrl: client?.avatar_url ?? null,
|
|
@@ -253,6 +254,10 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
|
|
|
253
254
|
contactName = person?.name as string | undefined
|
|
254
255
|
}
|
|
255
256
|
|
|
257
|
+
// Simple / Google-Calendar-style events have no client party — persist the
|
|
258
|
+
// free-text title in metadata so the read model can surface it as the name.
|
|
259
|
+
const eventTitle = input.title?.trim() || undefined
|
|
260
|
+
|
|
256
261
|
const { data: order, error: orderErr } = await core.from('orders').insert({
|
|
257
262
|
tenant_id: tenantId,
|
|
258
263
|
kind: input.kind ?? 'appointment',
|
|
@@ -267,6 +272,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
|
|
|
267
272
|
source: 'agenda',
|
|
268
273
|
...(serviceNames ? { serviceNames, itemsSummary: serviceNames } : {}),
|
|
269
274
|
...(contactName ? { contactName } : {}),
|
|
275
|
+
...(eventTitle ? { title: eventTitle } : {}),
|
|
270
276
|
},
|
|
271
277
|
}).select('id').single()
|
|
272
278
|
if (orderErr) throw orderErr
|
|
@@ -286,6 +292,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
|
|
|
286
292
|
source: 'agenda',
|
|
287
293
|
...(serviceNames ? { serviceNames, itemsSummary: serviceNames } : {}),
|
|
288
294
|
...(contactName ? { contactName } : {}),
|
|
295
|
+
...(eventTitle ? { title: eventTitle } : {}),
|
|
289
296
|
},
|
|
290
297
|
}).select('id').single()
|
|
291
298
|
if (bookingErr) throw bookingErr
|
|
@@ -315,7 +322,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
|
|
|
315
322
|
async updateBooking(id: string, data: UpdateBookingInput): Promise<CalendarBooking> {
|
|
316
323
|
const { core } = getClients()
|
|
317
324
|
const { data: existing, error: existingErr } = await core.from('bookings')
|
|
318
|
-
.select('id, order_id, starts_at, assignee_id')
|
|
325
|
+
.select('id, order_id, starts_at, assignee_id, metadata')
|
|
319
326
|
.eq('id', id)
|
|
320
327
|
.single()
|
|
321
328
|
if (existingErr) throw existingErr
|
|
@@ -323,6 +330,10 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
|
|
|
323
330
|
const updates: Record<string, unknown> = {}
|
|
324
331
|
if (data.startsAt) updates.starts_at = data.startsAt
|
|
325
332
|
if (data.endsAt) updates.ends_at = data.endsAt
|
|
333
|
+
// Simple-event title lives in metadata (no client party) — merge, don't clobber.
|
|
334
|
+
if (data.title !== undefined) {
|
|
335
|
+
updates.metadata = { ...(existing?.metadata as Record<string, unknown> ?? {}), title: data.title }
|
|
336
|
+
}
|
|
326
337
|
if (data.status) {
|
|
327
338
|
// Only update the agenda status — never touch the financial stage
|
|
328
339
|
updates.status = data.status
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
|
|
|
25
25
|
() => createMockAgendaProvider(),
|
|
26
26
|
)
|
|
27
27
|
const store = createAgendaStore(provider, options?.financialBridge)
|
|
28
|
+
const registries = [...agendaRegistries, ...(options?.settingsRegistries ?? [])]
|
|
28
29
|
|
|
29
30
|
// Register schedule block config globally so ScheduleEditor can read it
|
|
30
31
|
setScheduleBlockConfig({
|
|
@@ -55,6 +56,9 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
|
|
|
55
56
|
version: '1.0.0',
|
|
56
57
|
scope: options?.scope ?? 'universal',
|
|
57
58
|
verticalId: options?.verticalId,
|
|
59
|
+
// Booking/scheduling is business-ops (clinic, salon, services) — targets
|
|
60
|
+
// the saas/admin Panel world, not the ecommerce storefront.
|
|
61
|
+
scaffolds: ['saas'],
|
|
58
62
|
defaultEnabled: true,
|
|
59
63
|
dependencies: [],
|
|
60
64
|
declaredFeatures: [
|
|
@@ -152,19 +156,19 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
|
|
|
152
156
|
},
|
|
153
157
|
],
|
|
154
158
|
|
|
155
|
-
registries
|
|
159
|
+
registries,
|
|
156
160
|
|
|
157
161
|
settings: [
|
|
158
162
|
{
|
|
159
163
|
id: 'agenda',
|
|
160
164
|
label: 'Agenda',
|
|
161
165
|
icon: 'Calendar',
|
|
162
|
-
// Standard layout: General | Integrations (Google Calendar addon) | Properties
|
|
166
|
+
// Standard layout: General | Integrations (Google Calendar addon) | Properties.
|
|
163
167
|
component: (() => {
|
|
164
168
|
const AgendaSettingsTab: React.FC = () => React.createElement(PluginSettingsPanel, {
|
|
165
169
|
title: 'Agenda',
|
|
166
170
|
generalSettings: React.createElement(AgendaGeneralSettings),
|
|
167
|
-
registries
|
|
171
|
+
registries,
|
|
168
172
|
hostPluginId: 'agenda',
|
|
169
173
|
routeBase: '/settings/agenda',
|
|
170
174
|
})
|
|
@@ -183,6 +187,10 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
|
|
|
183
187
|
export type { AgendaPluginOptions } from './config'
|
|
184
188
|
export type { ResolvedAgendaConfig } from './config'
|
|
185
189
|
export type { AgendaDataProvider } from './data/types'
|
|
190
|
+
// Seedable in-memory provider — apps pass a seed to render the calendar with
|
|
191
|
+
// app-specific events instead of the built-in salon sample.
|
|
192
|
+
export { createMockAgendaProvider } from './data/mock'
|
|
193
|
+
export type { MockAgendaSeed, MockAgendaProviderOptions } from './data/mock'
|
|
186
194
|
export type { AgendaFinancialBridge, BookingPaymentStatus, BookingPaymentSummary, BookingPaymentDetail } from './financial-bridge'
|
|
187
195
|
export { createFinancialBridge, computeCommissionAmount } from './bridges/create-financial-bridge'
|
|
188
196
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// booking↔event mapping are all proven HERE — before/without the deployed edge
|
|
5
5
|
// function. The data-plane behaviors that need a live Google account are written
|
|
6
6
|
// as it.todo specs so the intended contract is documented and pending, not
|
|
7
|
-
// silently missing. See
|
|
7
|
+
// silently missing. See docs/PLUGIN-PATTERNS.md → integration capability.
|
|
8
8
|
import { describe, it, expect } from 'vitest'
|
|
9
9
|
import { assertConnectorContract } from '@fayz-ai/core/testing'
|
|
10
10
|
import { googleCalendarConnector } from './connector'
|