@fayz-ai/plugin-agenda 0.1.3

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.
Files changed (114) hide show
  1. package/README.md +51 -0
  2. package/dist/AgendaContext.d.ts +13 -0
  3. package/dist/AgendaContext.d.ts.map +1 -0
  4. package/dist/AgendaPage.d.ts +11 -0
  5. package/dist/AgendaPage.d.ts.map +1 -0
  6. package/dist/bridges/create-financial-bridge.d.ts +19 -0
  7. package/dist/bridges/create-financial-bridge.d.ts.map +1 -0
  8. package/dist/calendar-styles.d.ts +8 -0
  9. package/dist/calendar-styles.d.ts.map +1 -0
  10. package/dist/chunk-3LJKZQK4.js +12 -0
  11. package/dist/chunk-3LJKZQK4.js.map +1 -0
  12. package/dist/chunk-SYDBDI3Q.cjs +15 -0
  13. package/dist/chunk-SYDBDI3Q.cjs.map +1 -0
  14. package/dist/components/AgendaGeneralSettings.d.ts +3 -0
  15. package/dist/components/AgendaGeneralSettings.d.ts.map +1 -0
  16. package/dist/components/AppointmentModal.d.ts +16 -0
  17. package/dist/components/AppointmentModal.d.ts.map +1 -0
  18. package/dist/components/AppointmentPopover.d.ts +14 -0
  19. package/dist/components/AppointmentPopover.d.ts.map +1 -0
  20. package/dist/components/BookingPaymentPanel.d.ts +18 -0
  21. package/dist/components/BookingPaymentPanel.d.ts.map +1 -0
  22. package/dist/components/CalendarSidebar.d.ts +3 -0
  23. package/dist/components/CalendarSidebar.d.ts.map +1 -0
  24. package/dist/components/CalendarToolbar.d.ts +13 -0
  25. package/dist/components/CalendarToolbar.d.ts.map +1 -0
  26. package/dist/components/EventContextMenu.d.ts +14 -0
  27. package/dist/components/EventContextMenu.d.ts.map +1 -0
  28. package/dist/components/FloatingPanel.d.ts +27 -0
  29. package/dist/components/FloatingPanel.d.ts.map +1 -0
  30. package/dist/components/MiniCalendar.d.ts +9 -0
  31. package/dist/components/MiniCalendar.d.ts.map +1 -0
  32. package/dist/components/PaymentStatusBadge.d.ts +13 -0
  33. package/dist/components/PaymentStatusBadge.d.ts.map +1 -0
  34. package/dist/components/SearchCombobox.d.ts +38 -0
  35. package/dist/components/SearchCombobox.d.ts.map +1 -0
  36. package/dist/components/StatusBadge.d.ts +5 -0
  37. package/dist/components/StatusBadge.d.ts.map +1 -0
  38. package/dist/config.d.ts +151 -0
  39. package/dist/config.d.ts.map +1 -0
  40. package/dist/data/index.d.ts +4 -0
  41. package/dist/data/index.d.ts.map +1 -0
  42. package/dist/data/mock.d.ts +3 -0
  43. package/dist/data/mock.d.ts.map +1 -0
  44. package/dist/data/supabase.d.ts +3 -0
  45. package/dist/data/supabase.d.ts.map +1 -0
  46. package/dist/data/types.d.ts +26 -0
  47. package/dist/data/types.d.ts.map +1 -0
  48. package/dist/financial-bridge.d.ts +92 -0
  49. package/dist/financial-bridge.d.ts.map +1 -0
  50. package/dist/hooks/useAgendaSettings.d.ts +7 -0
  51. package/dist/hooks/useAgendaSettings.d.ts.map +1 -0
  52. package/dist/index.cjs +5135 -0
  53. package/dist/index.cjs.map +1 -0
  54. package/dist/index.d.ts +9 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +5120 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/lib/tenant.d.ts +3 -0
  59. package/dist/lib/tenant.d.ts.map +1 -0
  60. package/dist/locales/en.d.ts +2 -0
  61. package/dist/locales/en.d.ts.map +1 -0
  62. package/dist/locales/index.d.ts +2 -0
  63. package/dist/locales/index.d.ts.map +1 -0
  64. package/dist/locales/pt-BR.d.ts +2 -0
  65. package/dist/locales/pt-BR.d.ts.map +1 -0
  66. package/dist/registries.d.ts +3 -0
  67. package/dist/registries.d.ts.map +1 -0
  68. package/dist/store.d.ts +66 -0
  69. package/dist/store.d.ts.map +1 -0
  70. package/dist/tenant-222EHYUC.cjs +16 -0
  71. package/dist/tenant-222EHYUC.cjs.map +1 -0
  72. package/dist/tenant-LAUWVUYB.js +3 -0
  73. package/dist/tenant-LAUWVUYB.js.map +1 -0
  74. package/dist/types.d.ts +202 -0
  75. package/dist/types.d.ts.map +1 -0
  76. package/dist/views/CalendarView.d.ts +3 -0
  77. package/dist/views/CalendarView.d.ts.map +1 -0
  78. package/dist/views/ConfirmationsView.d.ts +3 -0
  79. package/dist/views/ConfirmationsView.d.ts.map +1 -0
  80. package/package.json +63 -0
  81. package/src/AgendaContext.tsx +11 -0
  82. package/src/AgendaPage.tsx +33 -0
  83. package/src/bridges/create-financial-bridge.ts +298 -0
  84. package/src/calendar-styles.css +245 -0
  85. package/src/calendar-styles.ts +285 -0
  86. package/src/components/AgendaGeneralSettings.tsx +64 -0
  87. package/src/components/AppointmentModal.tsx +1031 -0
  88. package/src/components/AppointmentPopover.tsx +221 -0
  89. package/src/components/BookingPaymentPanel.tsx +394 -0
  90. package/src/components/CalendarSidebar.tsx +119 -0
  91. package/src/components/CalendarToolbar.tsx +83 -0
  92. package/src/components/EventContextMenu.tsx +122 -0
  93. package/src/components/FloatingPanel.tsx +123 -0
  94. package/src/components/MiniCalendar.tsx +120 -0
  95. package/src/components/PaymentStatusBadge.tsx +62 -0
  96. package/src/components/SearchCombobox.tsx +216 -0
  97. package/src/components/StatusBadge.tsx +23 -0
  98. package/src/config.ts +269 -0
  99. package/src/data/index.ts +3 -0
  100. package/src/data/mock.ts +324 -0
  101. package/src/data/supabase.ts +570 -0
  102. package/src/data/types.ts +37 -0
  103. package/src/financial-bridge.ts +96 -0
  104. package/src/hooks/useAgendaSettings.ts +17 -0
  105. package/src/index.ts +180 -0
  106. package/src/lib/tenant.ts +3 -0
  107. package/src/locales/en.ts +207 -0
  108. package/src/locales/index.ts +7 -0
  109. package/src/locales/pt-BR.ts +207 -0
  110. package/src/registries.ts +36 -0
  111. package/src/store.ts +304 -0
  112. package/src/types.ts +265 -0
  113. package/src/views/CalendarView.tsx +904 -0
  114. package/src/views/ConfirmationsView.tsx +155 -0
@@ -0,0 +1,1031 @@
1
+ import React, { useState, useEffect, useMemo, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Clock, AlertTriangle, X, User, Briefcase, FileText, CircleDot, Plus, MapPin, Calendar, DollarSign, CheckSquare, Ban, Check, HandCoins } from 'lucide-react'
4
+
5
+ const BOOKING_TYPE_ICONS: Record<string, React.ElementType> = {
6
+ Calendar, CheckSquare, Ban,
7
+ }
8
+ import {
9
+ Modal, ModalContent, ModalTitle,
10
+ } from '@fayz-ai/ui'
11
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@fayz-ai/ui'
12
+ import { cn } from '@fayz-ai/ui'
13
+ import { SearchCombobox, type ComboboxOption } from './SearchCombobox'
14
+ import { PersonLink } from '@fayz-ai/saas'
15
+ import { BookingPaymentPanel } from './BookingPaymentPanel'
16
+ import { CrudDetailPage } from '@fayz-ai/saas'
17
+ import { CrudFormPage } from '@fayz-ai/saas'
18
+ import { Button } from '@fayz-ai/ui'
19
+ import { useAgendaConfig, useAgendaProvider, useAgendaStore } from '../AgendaContext'
20
+ import { useTranslation } from '@fayz-ai/core'
21
+ import { getCurrentLocale } from '@fayz-ai/core'
22
+ import { isStatusAvailable } from '../types'
23
+ import type { CreateBookingInput, BookingTypeId } from '../types'
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function fmtCurrency(amount: number, locale: string, code: string): string {
30
+ return new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format(amount)
31
+ }
32
+
33
+ function formatCompactDate(dateStr: string, locale: string): string {
34
+ const d = new Date(dateStr + 'T00:00:00')
35
+ return d.toLocaleDateString(locale === 'pt-BR' ? 'pt-BR' : 'en-US', {
36
+ weekday: 'short', month: 'short', day: 'numeric',
37
+ })
38
+ }
39
+
40
+ interface ServiceItem { serviceId: string; name: string; durationMinutes: number; price: number }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Inline editable number — allows clearing, commits on blur/enter
44
+ // ---------------------------------------------------------------------------
45
+
46
+ function InlineNumber({ value, onChange, min = 0, step = 1, className, disabled, tooltip }: {
47
+ value: number; onChange: (v: number) => void; min?: number; step?: number
48
+ className?: string; disabled?: boolean; tooltip?: string
49
+ }) {
50
+ const [draft, setDraft] = useState(String(value))
51
+ const [focused, setFocused] = useState(false)
52
+
53
+ useEffect(() => { if (!focused) setDraft(String(value)) }, [value, focused])
54
+
55
+ function commit() {
56
+ setFocused(false)
57
+ const v = parseFloat(draft)
58
+ if (!isNaN(v) && v >= min) {
59
+ onChange(v)
60
+ } else {
61
+ setDraft(String(value))
62
+ }
63
+ }
64
+
65
+ const input = (
66
+ <input
67
+ type="text"
68
+ inputMode="decimal"
69
+ value={draft}
70
+ onChange={(e) => setDraft(e.target.value)}
71
+ onFocus={(e) => { setFocused(true); e.target.select() }}
72
+ onBlur={commit}
73
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit() } }}
74
+ disabled={disabled}
75
+ className={cn(
76
+ 'text-right text-[11px] text-muted-foreground bg-transparent border-b border-transparent hover:border-muted-foreground/40 focus:border-foreground focus:outline-none tabular-nums',
77
+ className,
78
+ )}
79
+ />
80
+ )
81
+
82
+ if (!tooltip) return input
83
+
84
+ return (
85
+ <TooltipProvider delayDuration={400}>
86
+ <Tooltip>
87
+ <TooltipTrigger asChild>{input}</TooltipTrigger>
88
+ <TooltipContent side="top" className="text-xs">{tooltip}</TooltipContent>
89
+ </Tooltip>
90
+ </TooltipProvider>
91
+ )
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Custom Status Select
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function StatusSelect({ value, onChange, statuses, bookingDate }: {
99
+ value: string; onChange: (v: string) => void
100
+ statuses: Array<{ value: string; label: string; color: string; availableWhen?: string }>
101
+ bookingDate?: string
102
+ }) {
103
+ const [open, setOpen] = useState(false)
104
+ const ref = useRef<HTMLDivElement>(null)
105
+ const dropdownRef = useRef<HTMLDivElement>(null)
106
+ const current = statuses.find((s) => s.value === value)
107
+
108
+ useEffect(() => {
109
+ if (!open) return
110
+ const handler = (e: MouseEvent) => {
111
+ const target = e.target as Node
112
+ if (ref.current?.contains(target)) return
113
+ if (dropdownRef.current?.contains(target)) return
114
+ setOpen(false)
115
+ }
116
+ document.addEventListener('mousedown', handler)
117
+ return () => document.removeEventListener('mousedown', handler)
118
+ }, [open])
119
+
120
+ const btnRef = useRef<HTMLButtonElement>(null)
121
+ const [dropRect, setDropRect] = useState<DOMRect | null>(null)
122
+
123
+ function handleOpen() {
124
+ if (btnRef.current) setDropRect(btnRef.current.getBoundingClientRect())
125
+ setOpen((p) => !p)
126
+ }
127
+
128
+ return (
129
+ <div ref={ref} className="relative flex-1">
130
+ <button ref={btnRef} type="button" onClick={handleOpen}
131
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/30 transition-colors text-left w-full">
132
+ {current && <>
133
+ <span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: current.color }} />
134
+ <span>{current.label}</span>
135
+ </>}
136
+ <svg className="ml-auto h-3 w-3 text-muted-foreground shrink-0" viewBox="0 0 16 16" fill="none">
137
+ <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
138
+ </svg>
139
+ </button>
140
+ {open && dropRect && createPortal(
141
+ <div ref={dropdownRef} data-modal-passthrough style={{ position: 'fixed', left: dropRect.left, top: dropRect.bottom + 4, width: dropRect.width, zIndex: 9999, pointerEvents: 'auto' }}
142
+ className="fayz-glass-surface rounded-lg border bg-popover shadow-lg py-1 animate-in fade-in zoom-in-95 duration-100">
143
+ {statuses.map((s: any) => {
144
+ const available = !bookingDate || isStatusAvailable(s as any, bookingDate)
145
+ return (
146
+ <button key={s.value} type="button" disabled={!available}
147
+ onClick={() => { if (available) { onChange(s.value); setOpen(false) } }}
148
+ className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-sm transition-colors ${
149
+ !available ? 'opacity-30 cursor-not-allowed' : s.value === value ? 'bg-muted/30' : 'hover:bg-muted/50'
150
+ }`}>
151
+ <span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
152
+ <span>{s.label}</span>
153
+ </button>
154
+ )
155
+ })}
156
+ </div>,
157
+ document.body,
158
+ )}
159
+ </div>
160
+ )
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Professional Select — compact inline dropdown with initials
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function ProfessionalSelect({ value, onChange, professionals }: {
168
+ value: string; onChange: (v: string) => void
169
+ professionals: Array<{ id: string; name: string }>
170
+ }) {
171
+ const t = useTranslation()
172
+ const [open, setOpen] = useState(false)
173
+ const ref = useRef<HTMLDivElement>(null)
174
+ const current = professionals.find((p) => p.id === value)
175
+
176
+ useEffect(() => {
177
+ if (!open) return
178
+ const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
179
+ document.addEventListener('mousedown', handler)
180
+ return () => document.removeEventListener('mousedown', handler)
181
+ }, [open])
182
+
183
+ return (
184
+ <div ref={ref} className="relative flex-1">
185
+ <button type="button" onClick={() => setOpen((p) => !p)}
186
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/30 transition-colors text-left">
187
+ {current ? (
188
+ <span>{current.name}</span>
189
+ ) : (
190
+ <span className="text-muted-foreground">{t('agenda.appointment.selectProfessional')}</span>
191
+ )}
192
+ <svg className="ml-auto h-3 w-3 text-muted-foreground shrink-0" viewBox="0 0 16 16" fill="none">
193
+ <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
194
+ </svg>
195
+ </button>
196
+ {open && (
197
+ <div className="fayz-glass-surface absolute top-full left-0 right-0 z-50 mt-1 rounded-lg border bg-popover shadow-lg py-1 animate-in fade-in zoom-in-95 duration-100">
198
+ {professionals.map((p: any) => (
199
+ <button key={p.id} type="button"
200
+ onClick={() => { onChange(p.id); setOpen(false) }}
201
+ className={`flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50 transition-colors ${p.id === value ? 'bg-muted/30' : ''}`}>
202
+ <span>{p.name}</span>
203
+ </button>
204
+ ))}
205
+ </div>
206
+ )}
207
+ </div>
208
+ )
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Location Select — compact inline dropdown
213
+ // ---------------------------------------------------------------------------
214
+
215
+ function LocationSelect({ value, onChange, locations }: {
216
+ value: string; onChange: (v: string) => void
217
+ locations: Array<{ id: string; name: string }>
218
+ }) {
219
+ const t = useTranslation()
220
+ const [open, setOpen] = useState(false)
221
+ const ref = useRef<HTMLDivElement>(null)
222
+ const current = locations.find((l) => l.id === value)
223
+
224
+ useEffect(() => {
225
+ if (!open) return
226
+ const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
227
+ document.addEventListener('mousedown', handler)
228
+ return () => document.removeEventListener('mousedown', handler)
229
+ }, [open])
230
+
231
+ return (
232
+ <div ref={ref} className="relative flex-1">
233
+ <button type="button" onClick={() => setOpen((p) => !p)}
234
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/30 transition-colors text-left">
235
+ {current ? (
236
+ <span>{current.name}</span>
237
+ ) : (
238
+ <span className="text-muted-foreground">{t('agenda.appointment.selectLocation')}</span>
239
+ )}
240
+ <svg className="ml-auto h-3 w-3 text-muted-foreground shrink-0" viewBox="0 0 16 16" fill="none">
241
+ <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
242
+ </svg>
243
+ </button>
244
+ {open && (
245
+ <div className="fayz-glass-surface absolute top-full left-0 right-0 z-50 mt-1 rounded-lg border bg-popover shadow-lg py-1 animate-in fade-in zoom-in-95 duration-100 max-h-48 overflow-y-auto">
246
+ {locations.map((l: any) => (
247
+ <button key={l.id} type="button"
248
+ onClick={() => { onChange(l.id); setOpen(false) }}
249
+ className={`flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50 transition-colors ${l.id === value ? 'bg-muted/30' : ''}`}>
250
+ <span>{l.name}</span>
251
+ </button>
252
+ ))}
253
+ </div>
254
+ )}
255
+ </div>
256
+ )
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Appointment Modal — Google Calendar-inspired compact layout
261
+ // ---------------------------------------------------------------------------
262
+
263
+ interface Props {
264
+ open: boolean
265
+ mode: 'create' | 'edit'
266
+ bookingId?: string
267
+ prefill?: Partial<CreateBookingInput> & { endsAt?: string }
268
+ initialTab?: string
269
+ onClose: () => void
270
+ onCreated?: (bookingId: string) => void
271
+ }
272
+
273
+ export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, onClose, onCreated }: Props) {
274
+ const t = useTranslation()
275
+ const locale = getCurrentLocale()
276
+ const config = useAgendaConfig()
277
+ const provider = useAgendaProvider()
278
+ const initialSnapshot = useRef<string>('')
279
+ const createBooking = useAgendaStore((s) => s.createBooking)
280
+ const updateBooking = useAgendaStore((s) => s.updateBooking)
281
+ const deleteBooking = useAgendaStore((s) => s.deleteBooking)
282
+ const fetchBookings = useAgendaStore((s) => s.fetchBookings)
283
+ const agendaDateRange = useAgendaStore((s) => s.dateRange)
284
+ const professionals = useAgendaStore((s) => s.professionals)
285
+ const storeLocations = useAgendaStore((s) => s.locations)
286
+
287
+ // --- State ---
288
+ const [loading, setLoading] = useState(mode === 'edit')
289
+ const [bookingType, setBookingType] = useState<BookingTypeId>(prefill?.kind ?? 'appointment')
290
+ const [clientId, setClientId] = useState(prefill?.clientId ?? '')
291
+ const [clientSearch, setClientSearch] = useState('')
292
+ const [clientPhone, setClientPhone] = useState('')
293
+ const [professionalId, setProfessionalId] = useState(prefill?.professionalId ?? '')
294
+ const [locationId, setLocationId] = useState(prefill?.locationId ?? '')
295
+ const [services, setServices] = useState<ServiceItem[]>([])
296
+ const [serviceSearch, setServiceSearch] = useState('')
297
+ const [date, setDate] = useState(() => {
298
+ if (prefill?.startsAt) return prefill.startsAt.slice(0, 10)
299
+ return new Date().toISOString().slice(0, 10)
300
+ })
301
+ const [startTime, setStartTime] = useState(() => {
302
+ if (prefill?.startsAt) {
303
+ const d = new Date(prefill.startsAt)
304
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
305
+ }
306
+ return '09:00'
307
+ })
308
+ const [status, setStatus] = useState('scheduled')
309
+ const [notes, setNotes] = useState('')
310
+ const [showNotes, setShowNotes] = useState(false)
311
+ const [saving, setSaving] = useState(false)
312
+ const [conflict, setConflict] = useState(false)
313
+ const [quickCreate, setQuickCreate] = useState(false)
314
+ const [newClientName, setNewClientName] = useState('')
315
+ const [newClientPhone, setNewClientPhone] = useState('')
316
+ const [newClientEmail, setNewClientEmail] = useState('')
317
+ const [creatingClient, setCreatingClient] = useState(false)
318
+ const [clientOptions, setClientOptions] = useState<ComboboxOption[]>([])
319
+ const [clientSearching, setClientSearching] = useState(false)
320
+ const [serviceOptions, setServiceOptions] = useState<ComboboxOption[]>([])
321
+ const [initialServiceOptions, setInitialServiceOptions] = useState<ComboboxOption[]>([])
322
+ const [editingDate, setEditingDate] = useState(false)
323
+ const [editingTime, setEditingTime] = useState(false)
324
+ const [confirmingDelete, setConfirmingDelete] = useState(false)
325
+ const [deleting, setDeleting] = useState(false)
326
+ const [editOrderId, setEditOrderId] = useState<string | null>(null)
327
+ const [editOrderTotal, setEditOrderTotal] = useState(0)
328
+ const [editPaymentStatus, setEditPaymentStatus] = useState<import('../financial-bridge').BookingPaymentStatus>('none')
329
+ const [paymentStatusLoading, setPaymentStatusLoading] = useState(false)
330
+ const [modalTab, setModalTab] = useState<'appointment' | 'client' | 'financial'>('appointment')
331
+ const [clientData, setClientData] = useState<Record<string, any> | null>(null)
332
+ const [clientDataLoading, setClientDataLoading] = useState(false)
333
+ const [clientEditing, setClientEditing] = useState(false)
334
+
335
+ // Active booking type config
336
+ const activeType = config.bookingTypes.find((bt) => bt.id === bookingType) ?? config.bookingTypes[0]
337
+
338
+ // Reset form when modal opens
339
+ useEffect(() => {
340
+ if (!open) return
341
+ setClientId(prefill?.clientId ?? ''); setClientSearch(''); setClientPhone('')
342
+ setProfessionalId(prefill?.professionalId ?? ''); setLocationId(prefill?.locationId ?? ''); setServices([])
343
+ setServiceSearch(''); setNotes(''); setStatus('scheduled'); setShowNotes(false)
344
+ setConflict(false); setLoading(mode === 'edit')
345
+ setQuickCreate(false); setNewClientName(''); setNewClientPhone(''); setNewClientEmail(''); setCreatingClient(false)
346
+ setBookingType(prefill?.kind ?? 'appointment')
347
+ setEditingDate(false); setEditingTime(false); setConfirmingDelete(false); setDeleting(false)
348
+ setEditOrderId(null); setEditOrderTotal(0); setEditPaymentStatus('none'); setPaymentStatusLoading(false)
349
+ setClientData(null); setClientDataLoading(false); setClientEditing(false)
350
+ setModalTab((initialTab as 'appointment' | 'client' | 'financial') || 'appointment')
351
+ if (!prefill?.professionalId && professionals.length === 1) setProfessionalId(professionals[0].id)
352
+ setDate(() => {
353
+ if (prefill?.startsAt) return prefill.startsAt.slice(0, 10)
354
+ return new Date().toISOString().slice(0, 10)
355
+ })
356
+ setStartTime(() => {
357
+ if (prefill?.startsAt) {
358
+ const d = new Date(prefill.startsAt)
359
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
360
+ }
361
+ return '09:00'
362
+ })
363
+ }, [open])
364
+
365
+ // Edit: load existing
366
+ useEffect(() => {
367
+ if (!open || mode !== 'edit' || !bookingId) return
368
+ setLoading(true)
369
+ provider.getBookingById(bookingId).then((b) => {
370
+ if (!b) { setLoading(false); return }
371
+ setBookingType((b.kind as BookingTypeId) ?? 'appointment')
372
+ setClientId(b.clientId ?? ''); setClientSearch(b.clientName ?? '')
373
+ setProfessionalId(b.professionalId ?? ''); setLocationId(b.locationId ?? '')
374
+ setDate(b.startsAt.slice(0, 10))
375
+ const d = new Date(b.startsAt)
376
+ setStartTime(`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`)
377
+ setStatus(b.status); setNotes(b.notes ?? '')
378
+ if (b.notes) setShowNotes(true)
379
+ if (b.services) setServices(b.services.map((s: any) => ({
380
+ serviceId: s.serviceId ?? '', name: s.name, durationMinutes: s.durationMinutes, price: s.price,
381
+ })))
382
+ setEditOrderId(b.orderId ?? null)
383
+ setEditOrderTotal(b.orderTotal ?? 0)
384
+ setEditPaymentStatus(b.paymentStatus ?? 'none')
385
+ // Save initial snapshot for dirty check
386
+ const svcSnap = b.services ? b.services.map((s: any) => ({ id: s.serviceId ?? '', dur: s.durationMinutes, price: s.price })) : []
387
+ initialSnapshot.current = JSON.stringify({
388
+ clientId: b.clientId ?? '', professionalId: b.professionalId ?? '', locationId: b.locationId ?? '',
389
+ date: b.startsAt.slice(0, 10),
390
+ 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,
392
+ })
393
+ setLoading(false)
394
+ // Resolve payment status from bridge if available
395
+ if (b.orderId && config.financialBridge) {
396
+ setPaymentStatusLoading(true)
397
+ config.financialBridge.resolvePaymentStatuses([b.orderId]).then((map) => {
398
+ const summary = map.get(b.orderId!)
399
+ if (summary) {
400
+ setEditPaymentStatus(summary.status)
401
+ setEditOrderTotal(summary.totalAmount)
402
+ }
403
+ }).catch(() => {}).finally(() => setPaymentStatusLoading(false))
404
+ }
405
+ }).catch(() => setLoading(false))
406
+ }, [open, mode, bookingId])
407
+
408
+ // Fetch client data when client tab is opened
409
+ useEffect(() => {
410
+ if (modalTab !== 'client' || !clientId || !config.contactLookup || clientData) return
411
+ setClientDataLoading(true)
412
+ config.contactLookup.getById(clientId).then((result) => {
413
+ setClientData(result?.data ?? null)
414
+ setClientDataLoading(false)
415
+ }).catch(() => setClientDataLoading(false))
416
+ }, [modalTab, clientId])
417
+
418
+ // Fetch initial service list when modal opens
419
+ useEffect(() => {
420
+ if (!open || !config.serviceLookup) return
421
+ const lookup = config.serviceLookup
422
+ const fetch = lookup.list ? () => lookup.list!() : () => lookup.search('')
423
+ fetch().then((results) => {
424
+ const opts = results.map((r: any) => ({ id: r.id, label: r.label, subtitle: r.subtitle, price: r.price, data: r.data }))
425
+ setInitialServiceOptions(opts)
426
+ }).catch(() => setInitialServiceOptions([]))
427
+ }, [open])
428
+
429
+ // Search effects
430
+ useEffect(() => {
431
+ if (!config.contactLookup || clientSearch.length < 1 || clientId) { setClientOptions([]); setClientSearching(false); return }
432
+ setClientSearching(true)
433
+ const timer = setTimeout(async () => {
434
+ try {
435
+ const results = await config.contactLookup!.search(clientSearch)
436
+ setClientOptions(results.map((r: any) => ({ id: r.id, label: r.label, subtitle: r.subtitle, price: r.price, data: r.data })))
437
+ } catch { setClientOptions([]) }
438
+ setClientSearching(false)
439
+ }, 250)
440
+ return () => clearTimeout(timer)
441
+ }, [clientSearch, clientId])
442
+
443
+ useEffect(() => {
444
+ if (!config.serviceLookup || serviceSearch.length < 1) { setServiceOptions([]); return }
445
+ const timer = setTimeout(async () => {
446
+ try {
447
+ const results = await config.serviceLookup!.search(serviceSearch)
448
+ setServiceOptions(results.map((r: any) => ({ id: r.id, label: r.label, subtitle: r.subtitle, price: r.price, data: r.data })))
449
+ } catch { setServiceOptions([]) }
450
+ }, 250)
451
+ return () => clearTimeout(timer)
452
+ }, [serviceSearch])
453
+
454
+ // Computed
455
+ const serviceDuration = services.reduce((sum, s) => sum + s.durationMinutes, 0)
456
+ const selectionDuration = useMemo(() => {
457
+ if (!prefill?.startsAt || !prefill?.endsAt) return 0
458
+ return Math.round((new Date(prefill.endsAt).getTime() - new Date(prefill.startsAt).getTime()) / 60000)
459
+ }, [prefill?.startsAt, prefill?.endsAt])
460
+ const totalDuration = serviceDuration > 0 ? serviceDuration : selectionDuration
461
+ const totalPrice = services.reduce((sum, s) => sum + s.price, 0)
462
+ const { currency } = config
463
+
464
+ const endTime = useMemo(() => {
465
+ const [h, m] = startTime.split(':').map(Number)
466
+ const total = h * 60 + m + totalDuration
467
+ return `${String(Math.floor(total / 60) % 24).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`
468
+ }, [startTime, totalDuration])
469
+
470
+ // Conflict check
471
+ useEffect(() => {
472
+ if (!professionalId || totalDuration === 0) { setConflict(false); return }
473
+ const timer = setTimeout(() => {
474
+ provider.checkConflict({
475
+ assigneeId: professionalId, startsAt: `${date}T${startTime}:00`, endsAt: `${date}T${endTime}:00`, excludeBookingId: bookingId,
476
+ }).then(setConflict).catch(() => setConflict(false))
477
+ }, 300)
478
+ return () => clearTimeout(timer)
479
+ }, [professionalId, date, startTime, endTime, bookingId])
480
+
481
+ function selectClient(opt: ComboboxOption) {
482
+ setClientId(opt.id); setClientSearch(opt.label); setClientOptions([])
483
+ setClientPhone((opt.data?.phone as string) ?? '')
484
+ }
485
+ function addService(opt: ComboboxOption) {
486
+ const duration = (opt.data?.duration_minutes as number) || 60
487
+ setServices((p) => [...p, { serviceId: opt.id, name: opt.label, durationMinutes: duration, price: opt.price ?? 0 }])
488
+ setServiceSearch(''); setServiceOptions([])
489
+ }
490
+ function removeService(idx: number) { setServices((p) => p.filter((_, i) => i !== idx)) }
491
+
492
+ async function handleSubmit(e: React.FormEvent) {
493
+ e.preventDefault()
494
+ if (!canSubmit) return
495
+ setSaving(true)
496
+ try {
497
+ const startsAt = new Date(`${date}T${startTime}:00`).toISOString()
498
+ if (mode === 'create') {
499
+ const newBooking = await createBooking({ kind: bookingType, clientId, professionalId, locationId: locationId || undefined, startsAt, notes: notes || undefined,
500
+ services: services.map((s: any) => ({ serviceId: s.serviceId, name: s.name, durationMinutes: s.durationMinutes, price: s.price })) })
501
+ onClose()
502
+ onCreated?.(newBooking.id)
503
+ } else if (bookingId) {
504
+ await updateBooking(bookingId, {
505
+ clientId, professionalId, locationId: locationId || undefined, startsAt, notes: notes || undefined, status: status as any,
506
+ services: services.map((s: any) => ({ serviceId: s.serviceId, name: s.name, durationMinutes: s.durationMinutes, price: s.price })),
507
+ })
508
+ onClose()
509
+ }
510
+ } catch { /* toast */ }
511
+ setSaving(false)
512
+ }
513
+
514
+ async function handleDelete() {
515
+ if (!bookingId) return
516
+ setDeleting(true)
517
+ try { await deleteBooking(bookingId); onClose() } catch { /* toast */ }
518
+ setDeleting(false)
519
+ }
520
+
521
+ const isPaidBooking = editPaymentStatus === 'paid'
522
+
523
+ // Dirty check — only enable Update when something changed
524
+ 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]
527
+ )
528
+ const isDirty = mode === 'create' || formSnapshot !== initialSnapshot.current
529
+
530
+ const canSubmit = (
531
+ (!activeType.requiresClient || clientId) &&
532
+ (!activeType.fields.professional || professionalId) &&
533
+ (!activeType.requiresServices || services.length > 0) &&
534
+ !conflict && !saving
535
+ )
536
+
537
+ const showTabs = config.bookingTypes.length > 1
538
+
539
+ return (
540
+ <Modal open={open} onOpenChange={(v) => { if (!v) onClose() }}>
541
+ <ModalContent size="lg" noPadding>
542
+
543
+ {/* ═══ TOP — type tabs + date/time ═══ */}
544
+ <div className="px-5 pt-4 pb-3 shrink-0 space-y-3">
545
+ <ModalTitle className="sr-only">
546
+ {mode === 'create' ? t('agenda.appointment.new') : t('agenda.appointment.edit')}
547
+ </ModalTitle>
548
+
549
+ {/* Booking type tabs (create) or section tabs (edit) */}
550
+ {showTabs && mode === 'create' ? (
551
+ <div className="flex gap-1 border-b fayz-glass-divider">
552
+ {config.bookingTypes.map((bt) => {
553
+ const label = t(`agenda.bookingType.${bt.id}`) || bt.label
554
+ const isActive = bookingType === bt.id
555
+ const BtIcon = BOOKING_TYPE_ICONS[bt.icon] ?? Calendar
556
+ return (
557
+ <button key={bt.id} type="button" onClick={() => setBookingType(bt.id)}
558
+ className={`relative flex items-center gap-1.5 pb-2 px-2 text-xs font-medium transition-colors ${
559
+ isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
560
+ }`}>
561
+ <BtIcon className="h-3.5 w-3.5" />
562
+ {label}
563
+ {isActive && (
564
+ <span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-foreground" />
565
+ )}
566
+ </button>
567
+ )
568
+ })}
569
+ </div>
570
+ ) : mode === 'edit' && (
571
+ (config.clientEntityDef && clientId) || (config.modules.financial && editOrderId) ? (
572
+ <div className="flex gap-1 border-b fayz-glass-divider">
573
+ {([
574
+ { id: 'appointment' as const, label: t(`agenda.bookingType.${bookingType}`) || activeType.label, icon: Calendar, show: true },
575
+ { id: 'client' as const, label: t('agenda.appointment.clientTab'), icon: User, show: !!config.clientEntityDef && !!clientId },
576
+ { id: 'financial' as const, label: t('agenda.payment.title'), icon: DollarSign, show: config.modules.financial && !!editOrderId },
577
+ ]).filter((tab) => tab.show).map((tab) => {
578
+ const isActive = modalTab === tab.id
579
+ return (
580
+ <button key={tab.id} type="button" onClick={() => setModalTab(tab.id)}
581
+ className={`relative flex items-center gap-1.5 pb-2 px-2 text-xs font-medium transition-colors ${
582
+ isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
583
+ }`}>
584
+ <tab.icon className="h-3.5 w-3.5" />
585
+ {tab.label}
586
+ {tab.id === 'financial' && editPaymentStatus === 'paid' && (
587
+ <span className="h-1.5 w-1.5 rounded-full bg-success" />
588
+ )}
589
+ {tab.id === 'financial' && editPaymentStatus !== 'paid' && (
590
+ (editPaymentStatus !== 'none' || (totalPrice > 0 && date <= new Date().toISOString().slice(0, 10)))
591
+ ) && (
592
+ <span className="h-1.5 w-1.5 rounded-full bg-warning" />
593
+ )}
594
+ {isActive && (
595
+ <span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-foreground" />
596
+ )}
597
+ </button>
598
+ )
599
+ })}
600
+ </div>
601
+ ) : (
602
+ <h2 className="text-sm font-semibold">
603
+ {t(`agenda.bookingType.${bookingType}`) || activeType.label}
604
+ </h2>
605
+ )
606
+ )}
607
+
608
+ {/* Date/time + professional row — hidden on financial tab */}
609
+ {modalTab === 'appointment' && (
610
+ <>
611
+ <div className="flex items-center gap-3">
612
+ <Clock className="h-4 w-4 text-muted-foreground shrink-0" />
613
+ <div className="flex items-center gap-1.5 text-sm flex-1 min-w-0">
614
+ {editingDate ? (
615
+ <input type="date" value={date} autoFocus
616
+ onChange={(e) => { setDate(e.target.value); setEditingDate(false) }}
617
+ onBlur={() => setEditingDate(false)}
618
+ className="bg-transparent text-sm font-medium focus:outline-none border-b border-foreground w-auto shrink-0" />
619
+ ) : (
620
+ <button type="button" onClick={() => setEditingDate(true)}
621
+ className="px-0.5 py-0.5 font-medium border-b border-transparent hover:border-foreground transition-colors shrink-0">
622
+ {formatCompactDate(date, locale)}
623
+ </button>
624
+ )}
625
+ {editingTime ? (
626
+ <input type="time" value={startTime} autoFocus
627
+ onChange={(e) => { if (e.target.value) { setStartTime(e.target.value); setEditingTime(false) } }}
628
+ onBlur={() => setEditingTime(false)}
629
+ className="bg-transparent text-xs text-muted-foreground focus:outline-none border-b border-foreground w-auto shrink-0" />
630
+ ) : (
631
+ <button type="button" onClick={() => setEditingTime(true)}
632
+ className="px-0.5 py-0.5 text-xs text-muted-foreground border-b border-transparent hover:border-foreground transition-colors shrink-0">
633
+ {startTime}
634
+ </button>
635
+ )}
636
+ <span className="text-[11px] text-muted-foreground/60">—</span>
637
+ <span className="text-[11px] text-muted-foreground/60">{endTime}</span>
638
+
639
+ {/* Professional — right-aligned on the same row */}
640
+ {activeType.fields.professional && (
641
+ <div className="ml-auto shrink-0">
642
+ <ProfessionalSelect value={professionalId} onChange={setProfessionalId} professionals={professionals} />
643
+ </div>
644
+ )}
645
+ </div>
646
+ </div>
647
+
648
+ {conflict && (
649
+ <div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
650
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0" />
651
+ {t('agenda.appointment.conflict')}
652
+ </div>
653
+ )}
654
+ </>
655
+ )}
656
+ </div>
657
+
658
+ {/* ═══ BODY ═══ */}
659
+ <div className="flex flex-col flex-1 min-h-0 overflow-hidden">
660
+
661
+ {/* Client tab content — reuses the same CrudDetailPage as /clients/:id */}
662
+ {modalTab === 'client' && clientId && config.clientEntityDef && (
663
+ <div className="flex-1 overflow-y-auto px-5 py-4">
664
+ {clientDataLoading ? (
665
+ <div className="space-y-3 py-2">
666
+ {[...Array(4)].map((_, i) => (
667
+ <div key={i} className="h-8 rounded-lg bg-muted animate-pulse" />
668
+ ))}
669
+ </div>
670
+ ) : clientData ? (
671
+ clientEditing ? (
672
+ <CrudFormPage
673
+ embedded
674
+ entityDef={config.clientEntityDef}
675
+ mode="edit"
676
+ initialData={clientData}
677
+ namePlural=""
678
+ onCancel={() => setClientEditing(false)}
679
+ onSubmit={async (data) => {
680
+ try {
681
+ const { getSupabaseClientOptional } = await import('@fayz-ai/core')
682
+ const supabase = getSupabaseClientOptional() as any
683
+ if (!supabase) return
684
+ const dbData: Record<string, unknown> = {}
685
+ for (const [k, v] of Object.entries(data)) {
686
+ dbData[k.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase())] = v
687
+ }
688
+ await supabase.schema('saas_core').from('persons').update(dbData).eq('id', clientId)
689
+ setClientData({ ...clientData, ...data })
690
+ setClientEditing(false)
691
+ } catch { /* toast */ }
692
+ }}
693
+ />
694
+ ) : (
695
+ <CrudDetailPage
696
+ embedded
697
+ entityDef={config.clientEntityDef}
698
+ item={clientData}
699
+ namePlural=""
700
+ basePath=""
701
+ onBack={() => setModalTab('appointment')}
702
+ onEdit={() => setClientEditing(true)}
703
+ />
704
+ )
705
+ ) : (
706
+ <p className="text-sm text-muted-foreground py-4 text-center">{t('agenda.appointment.noClientData')}</p>
707
+ )}
708
+ </div>
709
+ )}
710
+
711
+ {/* Financial tab content */}
712
+ {modalTab === 'financial' && editOrderId && config.financialBridge && (
713
+ <div className="flex-1 overflow-y-auto">
714
+ <BookingPaymentPanel
715
+ orderId={editOrderId}
716
+ orderTotal={editOrderTotal}
717
+ services={services.map((s: any) => ({ name: s.name, price: s.price, durationMinutes: s.durationMinutes }))}
718
+ formatCurrency={(v) => fmtCurrency(v, currency.locale, currency.code)}
719
+ inline
720
+ onClose={() => setModalTab('appointment')}
721
+ onPaymentChange={() => {
722
+ config.financialBridge!.getPaymentDetail(editOrderId).then((d) => {
723
+ if (d) { setEditPaymentStatus(d.status); setEditOrderTotal(d.totalAmount) }
724
+ }).catch(() => {})
725
+ // Propagate to the calendar card + popover badge (booking.paymentStatus
726
+ // is ledger-derived) so they update without a page refresh.
727
+ if (agendaDateRange.start) fetchBookings(agendaDateRange)
728
+ }}
729
+ />
730
+ </div>
731
+ )}
732
+
733
+ {/* Appointment tab content */}
734
+ <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0 overflow-hidden" style={{ display: modalTab === 'appointment' ? undefined : 'none' }}>
735
+ <div className="border-t fayz-glass-divider px-5 py-3 space-y-1 flex-1 overflow-y-auto">
736
+
737
+ {/* Loading skeleton */}
738
+ {loading && (
739
+ <div className="space-y-2 py-2">
740
+ {[...Array(4)].map((_, i) => (
741
+ <div key={i} className="flex items-center gap-3 py-1.5">
742
+ <div className="h-4 w-4 rounded bg-muted animate-pulse shrink-0" />
743
+ <div className="h-8 flex-1 rounded-lg bg-muted animate-pulse" />
744
+ </div>
745
+ ))}
746
+ </div>
747
+ )}
748
+
749
+ {!loading && <>
750
+
751
+ {/* Client */}
752
+ {activeType.fields.client && (
753
+ <div className="flex items-center gap-3 py-1">
754
+ <User className="h-4 w-4 text-muted-foreground shrink-0" />
755
+ <div className="flex-1">
756
+ {clientId && !quickCreate ? (
757
+ <div className="flex items-center gap-2 rounded-md bg-muted/30 px-2.5 py-1.5 text-sm">
758
+ <PersonLink personId={clientId} name={clientSearch} size="default" className="flex-1 min-w-0" />
759
+ {clientPhone && <span className="text-[10px] text-muted-foreground shrink-0">{clientPhone}</span>}
760
+ {!isPaidBooking && (
761
+ <button type="button" onClick={() => { setClientId(''); setClientSearch(''); setClientPhone('') }}
762
+ className="p-0.5 text-muted-foreground hover:text-destructive shrink-0">
763
+ <X className="h-3 w-3" />
764
+ </button>
765
+ )}
766
+ </div>
767
+ ) : !quickCreate ? (
768
+ <SearchCombobox
769
+ value={clientSearch}
770
+ onChange={(v) => { setClientSearch(v); setClientId('') }}
771
+ onSelect={selectClient}
772
+ options={clientOptions}
773
+ loading={clientSearching}
774
+ placeholder={t('agenda.appointment.searchClient')}
775
+ allowCreate
776
+ createLabel={t('agenda.appointment.newClient')}
777
+ onCreateNew={(name) => { setQuickCreate(true); setNewClientName(name); setNewClientPhone(''); setNewClientEmail('') }}
778
+ minimal
779
+ inlineLabel={t('agenda.appointment.addClient')}
780
+ />
781
+ ) : (
782
+ <div className="rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] p-2.5 space-y-2">
783
+ <div className="flex items-center gap-2">
784
+ <input type="text" value={newClientName} onChange={(e) => setNewClientName(e.target.value)}
785
+ placeholder={t('agenda.appointment.name')} className="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" autoFocus />
786
+ <button type="button" onClick={() => setQuickCreate(false)}
787
+ className="p-1 text-muted-foreground hover:text-foreground shrink-0"><X className="h-3.5 w-3.5" /></button>
788
+ </div>
789
+ <div className="grid grid-cols-2 gap-2">
790
+ <input type="tel" value={newClientPhone} onChange={(e) => setNewClientPhone(e.target.value)}
791
+ placeholder={t('agenda.appointment.phone')} className="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 focus:outline-none focus:ring-2 focus:ring-ring" />
792
+ <input type="email" value={newClientEmail} onChange={(e) => setNewClientEmail(e.target.value)}
793
+ placeholder={t('agenda.appointment.emailOptional')} className="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 focus:outline-none focus:ring-2 focus:ring-ring" />
794
+ </div>
795
+ <button type="button" disabled={creatingClient || !newClientName.trim()}
796
+ onClick={async () => {
797
+ setCreatingClient(true)
798
+ try {
799
+ const { getSupabaseClientOptional } = await import('@fayz-ai/core')
800
+ const { getAgendaTenantId } = await import('../lib/tenant')
801
+ const supabase = getSupabaseClientOptional() as any
802
+ const tenantId = getAgendaTenantId()
803
+ if (!supabase || !tenantId) throw new Error('Not initialized')
804
+
805
+ // 1. Insert into saas_core.persons (archetype table)
806
+ const { data: personRow, error: personError } = await supabase.schema('saas_core').from('persons').insert({
807
+ tenant_id: tenantId, kind: config.clientKind, name: newClientName.trim(),
808
+ phone: newClientPhone.trim() || null, email: newClientEmail.trim() || null,
809
+ }).select('id, name').single()
810
+ if (personError) throw personError
811
+
812
+ // 2. Insert into public.clients (project extension table)
813
+ const { error: clientError } = await supabase.from('clients').insert({
814
+ person_id: personRow.id, tenant_id: tenantId,
815
+ })
816
+ if (clientError) {
817
+ // Rollback person if extension insert fails
818
+ await supabase.schema('saas_core').from('persons').delete().eq('id', personRow.id)
819
+ throw clientError
820
+ }
821
+
822
+ setClientId(personRow.id); setClientSearch(personRow.name); setQuickCreate(false)
823
+ } catch (err: any) {
824
+ const { toast } = await import('@fayz-ai/ui')
825
+ toast.error(t('agenda.appointment.createClientFailed'), { description: err?.message })
826
+ }
827
+ setCreatingClient(false)
828
+ }}
829
+ className="w-full rounded-md bg-primary border border-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset disabled:opacity-40 transition-colors">
830
+ {creatingClient ? t('agenda.appointment.creating') : t('agenda.appointment.create')}
831
+ </button>
832
+ </div>
833
+ )}
834
+ </div>
835
+ </div>
836
+ )}
837
+
838
+ {/* Location */}
839
+ {activeType.fields.location && config.modules.locationSelection && (storeLocations.length > 0 || config.locations.length > 0) && (
840
+ <div className="flex items-center gap-3 py-1">
841
+ <MapPin className="h-4 w-4 text-muted-foreground shrink-0" />
842
+ <LocationSelect
843
+ value={locationId}
844
+ onChange={setLocationId}
845
+ locations={storeLocations.length > 0 ? storeLocations : config.locations}
846
+ />
847
+ </div>
848
+ )}
849
+
850
+ {/* Services */}
851
+ {activeType.fields.services && (
852
+ <div className="flex items-start gap-3 py-1">
853
+ <Briefcase className="h-4 w-4 mt-1.5 text-muted-foreground shrink-0" />
854
+ <div className="flex-1">
855
+ {services.length > 0 && (
856
+ <div className="space-y-0.5 mb-1">
857
+ {services.map((svc, idx) => (
858
+ <div key={idx} className="flex items-center rounded-md bg-muted/30 px-2.5 py-1.5 text-sm">
859
+ <span className="flex-1 truncate min-w-0">{svc.name}</span>
860
+ <div className="flex items-center gap-0.5 shrink-0 ml-3">
861
+ <InlineNumber
862
+ value={svc.durationMinutes}
863
+ onChange={(v) => setServices((p) => p.map((s, i) => i === idx ? { ...s, durationMinutes: Math.round(v) } : s))}
864
+ min={5}
865
+ step={5}
866
+ className="w-10 shrink-0"
867
+ disabled={isPaidBooking}
868
+ tooltip={t('agenda.appointment.duration')}
869
+ />
870
+ <span className="text-[11px] text-muted-foreground">min</span>
871
+ </div>
872
+ <div className="flex items-center gap-0.5 shrink-0 ml-3">
873
+ <span className="text-[11px] text-muted-foreground">{currency.symbol}</span>
874
+ <InlineNumber
875
+ value={svc.price}
876
+ onChange={(v) => setServices((p) => p.map((s, i) => i === idx ? { ...s, price: v } : s))}
877
+ min={0}
878
+ step={0.01}
879
+ className="w-14 shrink-0"
880
+ disabled={isPaidBooking}
881
+ tooltip={t('agenda.appointment.price')}
882
+ />
883
+ </div>
884
+ {!isPaidBooking && (
885
+ <button type="button" onClick={() => removeService(idx)} className="p-0.5 ml-2 text-muted-foreground hover:text-destructive shrink-0">
886
+ <X className="h-3 w-3" />
887
+ </button>
888
+ )}
889
+ </div>
890
+ ))}
891
+ </div>
892
+ )}
893
+
894
+ {!isPaidBooking && <SearchCombobox
895
+ value={serviceSearch}
896
+ onChange={setServiceSearch}
897
+ onSelect={addService}
898
+ options={serviceOptions}
899
+ placeholder={t('agenda.appointment.searchService')}
900
+ onBlurEmpty={() => {}}
901
+ onFocus={() => { if (!serviceSearch && serviceOptions.length === 0) setServiceOptions(initialServiceOptions) }}
902
+ renderRight={(opt) => {
903
+ const dur = (opt.data?.duration_minutes as number) || 0
904
+ const hasPrice = opt.price != null
905
+ if (!hasPrice && !dur) return null
906
+ return (
907
+ <span className="text-muted-foreground text-xs whitespace-nowrap">
908
+ {hasPrice && fmtCurrency(opt.price!, currency.locale, currency.code)}
909
+ {hasPrice && dur > 0 && ' · '}
910
+ {dur > 0 && `${dur}min`}
911
+ </span>
912
+ )
913
+ }}
914
+ minimal
915
+ inlineLabel={services.length > 0 ? t('agenda.appointment.addService') : t('agenda.appointment.addServiceFirst')}
916
+ inlineIcon={services.length > 0 ? 'plus' : 'search'}
917
+ />}
918
+ </div>
919
+ </div>
920
+ )}
921
+
922
+ {/* Status */}
923
+ {activeType.fields.status && (
924
+ <div className="flex items-center gap-3 py-1">
925
+ <CircleDot className="h-4 w-4 text-muted-foreground shrink-0" />
926
+ <StatusSelect value={status} onChange={setStatus} statuses={config.statuses} bookingDate={date} />
927
+ </div>
928
+ )}
929
+
930
+ {/* Notes — progressive disclosure */}
931
+ {!showNotes ? (
932
+ <div className="flex items-center gap-3 py-1">
933
+ <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
934
+ <button type="button" onClick={() => setShowNotes(true)}
935
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors text-left py-1.5 pl-2">
936
+ {t('agenda.appointment.addNotes')}
937
+ </button>
938
+ </div>
939
+ ) : (
940
+ <div className="flex items-start gap-3 py-1">
941
+ <FileText className="h-4 w-4 mt-2 text-muted-foreground shrink-0" />
942
+ <textarea value={notes} onChange={(e) => setNotes(e.target.value)}
943
+ placeholder={t('agenda.appointment.addDescription')} rows={2} autoFocus
944
+ 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 resize-none focus:outline-none focus:ring-2 focus:ring-ring" />
945
+ </div>
946
+ )}
947
+
948
+ </>}
949
+ </div>
950
+
951
+ {/* Appointment footer — inside form for submit */}
952
+ {modalTab === 'appointment' && !clientEditing && (
953
+ <div className="px-5 py-2.5 border-t fayz-glass-divider bg-transparent shrink-0 sm:rounded-b-2xl">
954
+ {confirmingDelete ? (
955
+ <div className="flex items-center justify-between">
956
+ <span className="text-xs text-destructive font-medium">{t('agenda.appointment.deleteConfirm')}</span>
957
+ <div className="flex items-center gap-2">
958
+ <Button type="button" variant="outline" size="sm" onClick={() => setConfirmingDelete(false)}>
959
+ {t('agenda.appointment.cancel')}
960
+ </Button>
961
+ <Button type="button" variant="destructive" size="sm" onClick={handleDelete} disabled={deleting}>
962
+ {deleting ? t('agenda.appointment.deleting') : t('agenda.appointment.confirmDelete')}
963
+ </Button>
964
+ </div>
965
+ </div>
966
+ ) : (
967
+ <div className="flex items-center justify-between">
968
+ <div className="flex items-center gap-3">
969
+ {mode === 'edit' && (
970
+ <button type="button" onClick={() => setConfirmingDelete(true)} className="text-xs text-destructive font-medium hover:underline">{t('agenda.appointment.delete')}</button>
971
+ )}
972
+ {services.length > 0 && (
973
+ <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
974
+ <Clock className="h-3 w-3" />
975
+ {totalDuration}min
976
+ {config.modules.financial && totalPrice > 0 && mode === 'edit' ? (
977
+ paymentStatusLoading ? (
978
+ <span className="h-4 w-16 rounded-full bg-muted animate-pulse" />
979
+ ) : isPaidBooking ? (
980
+ <button type="button" onClick={(e) => { e.stopPropagation(); setModalTab('financial') }}
981
+ className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-[10px] font-medium text-success hover:bg-success/20 transition-colors">
982
+ <Check className="h-2.5 w-2.5" />
983
+ {fmtCurrency(totalPrice, currency.locale, currency.code)} &middot; {t('agenda.payment.paid')}
984
+ </button>
985
+ ) : date <= new Date().toISOString().slice(0, 10) ? (
986
+ <button type="button" onClick={(e) => { e.stopPropagation(); setModalTab('financial') }}
987
+ className="inline-flex items-center gap-1 rounded-full bg-warning/10 px-2 py-0.5 text-[10px] font-medium text-warning hover:bg-warning/20 transition-colors">
988
+ <HandCoins className="h-3 w-3" />
989
+ {fmtCurrency(totalPrice, currency.locale, currency.code)} &middot; {t('agenda.payment.collect')}
990
+ </button>
991
+ ) : (
992
+ <span>&middot; {fmtCurrency(totalPrice, currency.locale, currency.code)}</span>
993
+ )
994
+ ) : (
995
+ <span>&middot; {fmtCurrency(totalPrice, currency.locale, currency.code)}</span>
996
+ )}
997
+ </span>
998
+ )}
999
+ </div>
1000
+ <div className="flex items-center gap-2">
1001
+ <Button type="button" variant="outline" size="sm" onClick={onClose} className="hidden sm:inline-flex">
1002
+ {t('agenda.appointment.cancel')}
1003
+ </Button>
1004
+ <Button type="submit" variant="default" size="sm" disabled={!canSubmit || !isDirty}>
1005
+ {saving ? t('agenda.appointment.saving') : mode === 'create' ? t('agenda.appointment.save') : t('agenda.appointment.update')}
1006
+ </Button>
1007
+ </div>
1008
+ </div>
1009
+ )}
1010
+ </div>
1011
+ )}
1012
+ </form>
1013
+
1014
+ {/* Non-appointment tab footer — outside form */}
1015
+ {modalTab !== 'appointment' && !clientEditing && (
1016
+ <div className="px-5 py-2.5 border-t fayz-glass-divider bg-transparent shrink-0 sm:rounded-b-2xl">
1017
+ <div className="flex items-center">
1018
+ <button type="button" onClick={() => { setModalTab('appointment'); setClientEditing(false) }}
1019
+ className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors">
1020
+ <svg className="h-3 w-3" viewBox="0 0 16 16" fill="none"><path d="M10 12L6 8l4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
1021
+ {t('agenda.payment.backToAppointment')}
1022
+ </button>
1023
+ </div>
1024
+ </div>
1025
+ )}
1026
+ </div>
1027
+ </ModalContent>
1028
+
1029
+ </Modal>
1030
+ )
1031
+ }