@fayz-ai/plugin-agenda 0.9.0 → 0.10.0

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