@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,570 @@
1
+ import type { AgendaDataProvider } from './types'
2
+ import type {
3
+ CalendarBooking, Professional, Schedule, TimeSlot,
4
+ CreateBookingInput, UpdateBookingInput, SaveScheduleInput,
5
+ BookingQuery, ConflictCheckParams, AvailableSlotsParams, BookingStatus,
6
+ } from '../types'
7
+ import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
8
+ import { getAgendaTenantId } from '../lib/tenant'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function getTenantId(): string | undefined {
15
+ // Plugin-local override wins; else fall back to the app's active tenant so
16
+ // writes stamp the right tenant_id and pass orders/bookings RLS.
17
+ return getAgendaTenantId() ?? getActiveTenantId()
18
+ }
19
+
20
+ function snakeToCamel(obj: Record<string, unknown>): Record<string, unknown> {
21
+ const result: Record<string, unknown> = {}
22
+ for (const [key, value] of Object.entries(obj)) {
23
+ result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value
24
+ }
25
+ return result
26
+ }
27
+
28
+ function getClients() {
29
+ const supabase = getSupabaseClientOptional() as any
30
+ if (!supabase) throw new Error('Supabase not initialized')
31
+ return { core: supabase.schema('saas_core'), pub: supabase }
32
+ }
33
+
34
+ function mapBooking(row: Record<string, unknown>): CalendarBooking {
35
+ return snakeToCamel(row) as unknown as CalendarBooking
36
+ }
37
+
38
+ function mapSchedule(row: Record<string, unknown>): Schedule {
39
+ return snakeToCamel(row) as unknown as Schedule
40
+ }
41
+
42
+ function addMinutes(iso: string, minutes: number): string {
43
+ const d = new Date(iso)
44
+ d.setMinutes(d.getMinutes() + minutes)
45
+ return d.toISOString()
46
+ }
47
+
48
+ function unique(values: Array<string | null | undefined>): string[] {
49
+ return Array.from(new Set(values.filter((value): value is string => !!value)))
50
+ }
51
+
52
+ function indexById(rows: any[] | null | undefined): Map<string, any> {
53
+ return new Map((rows ?? []).filter((row) => row?.id).map((row) => [row.id, row]))
54
+ }
55
+
56
+ function groupBy<T>(rows: T[], getKey: (row: T) => string | null | undefined): Map<string, T[]> {
57
+ const grouped = new Map<string, T[]>()
58
+ for (const row of rows) {
59
+ const key = getKey(row)
60
+ if (!key) continue
61
+ grouped.set(key, [...(grouped.get(key) ?? []), row])
62
+ }
63
+ return grouped
64
+ }
65
+
66
+ function getDurationMinutes(startsAt: string, endsAt: string | null | undefined): number | null {
67
+ if (!endsAt) return null
68
+ return Math.round((new Date(endsAt).getTime() - new Date(startsAt).getTime()) / 60000)
69
+ }
70
+
71
+ function mergeBookings(viewBookings: CalendarBooking[], canonicalBookings: CalendarBooking[]): CalendarBooking[] {
72
+ const canonicalIds = new Set(canonicalBookings.map((booking) => booking.id))
73
+ const canonicalOrderIds = new Set(canonicalBookings.map((booking) => booking.orderId).filter(Boolean))
74
+ const merged = [
75
+ ...canonicalBookings,
76
+ ...viewBookings.filter((booking) => {
77
+ if (canonicalIds.has(booking.id)) return false
78
+ if (booking.orderId && canonicalOrderIds.has(booking.orderId)) return false
79
+ if (canonicalOrderIds.has(booking.id)) return false
80
+ return true
81
+ }),
82
+ ]
83
+
84
+ return merged.sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime())
85
+ }
86
+
87
+ async function hydrateCanonicalBookings(core: any, bookings: any[]): Promise<CalendarBooking[]> {
88
+ const personIds = unique(bookings.flatMap((booking) => [booking.party_id, booking.assignee_id]))
89
+ const locationIds = unique(bookings.map((booking) => booking.location_id))
90
+ const orderIds = unique(bookings.map((booking) => booking.order_id))
91
+ const bookingIds = unique(bookings.map((booking) => booking.id))
92
+
93
+ const [
94
+ { data: persons, error: personsError },
95
+ { data: locations, error: locationsError },
96
+ { data: orders, error: ordersError },
97
+ { data: items, error: itemsError },
98
+ ] = await Promise.all([
99
+ personIds.length
100
+ ? core.from('persons').select('id, name, phone, email, avatar_url').in('id', personIds)
101
+ : Promise.resolve({ data: [], error: null }),
102
+ locationIds.length
103
+ ? core.from('locations').select('id, name').in('id', locationIds)
104
+ : Promise.resolve({ data: [], error: null }),
105
+ orderIds.length
106
+ ? core.from('orders').select('id, total, status').in('id', orderIds)
107
+ : Promise.resolve({ data: [], error: null }),
108
+ orderIds.length
109
+ ? core.from('order_items').select('id, order_id, service_id, name, duration_minutes, unit_price, assignee_id, sort_order').in('order_id', orderIds)
110
+ : Promise.resolve({ data: [], error: null }),
111
+ ])
112
+
113
+ if (personsError) throw personsError
114
+ if (locationsError) throw locationsError
115
+ if (ordersError) throw ordersError
116
+ if (itemsError) throw itemsError
117
+
118
+ const personsById = indexById(persons)
119
+ const locationsById = indexById(locations)
120
+ const ordersById = indexById(orders)
121
+ // S4: line items come from order_items (single source), keyed by order_id.
122
+ const itemsByOrderId = groupBy(items ?? [], (item: any) => item.order_id)
123
+
124
+ return bookings.map((booking) => {
125
+ const client = booking.party_id ? personsById.get(booking.party_id) : null
126
+ const professional = booking.assignee_id ? personsById.get(booking.assignee_id) : null
127
+ const location = booking.location_id ? locationsById.get(booking.location_id) : null
128
+ const order = booking.order_id ? ordersById.get(booking.order_id) : null
129
+ const bookingItems = ((booking.order_id ? itemsByOrderId.get(booking.order_id) : null) ?? [])
130
+ .sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
131
+ .map((item: any) => ({
132
+ id: item.id,
133
+ serviceId: item.service_id,
134
+ name: item.name,
135
+ durationMinutes: item.duration_minutes,
136
+ price: item.unit_price,
137
+ assigneeId: item.assignee_id,
138
+ }))
139
+ const itemDuration = bookingItems.reduce((sum: number, item: any) => sum + (item.durationMinutes ?? 0), 0)
140
+
141
+ return {
142
+ id: booking.id,
143
+ tenantId: booking.tenant_id,
144
+ kind: booking.kind,
145
+ startsAt: booking.starts_at,
146
+ endsAt: booking.ends_at,
147
+ status: booking.status,
148
+ notes: booking.notes,
149
+ orderId: booking.order_id,
150
+ locationId: booking.location_id,
151
+ metadata: booking.metadata ?? {},
152
+ clientId: booking.party_id,
153
+ clientName: client?.name ?? null,
154
+ clientPhone: client?.phone ?? null,
155
+ clientEmail: client?.email ?? null,
156
+ clientAvatarUrl: client?.avatar_url ?? null,
157
+ professionalId: booking.assignee_id,
158
+ professionalName: professional?.name ?? null,
159
+ professionalAvatarUrl: professional?.avatar_url ?? null,
160
+ locationName: location?.name ?? null,
161
+ orderTotal: order?.total ?? null,
162
+ orderStatus: order?.status ?? null,
163
+ services: bookingItems.length ? bookingItems : null,
164
+ totalDurationMinutes: itemDuration || getDurationMinutes(booking.starts_at, booking.ends_at),
165
+ createdAt: booking.created_at,
166
+ updatedAt: booking.updated_at,
167
+ } satisfies CalendarBooking
168
+ })
169
+ }
170
+
171
+ async function getCanonicalBookings(core: any, query: BookingQuery): Promise<CalendarBooking[]> {
172
+ let q = core.from('bookings')
173
+ .select('id, tenant_id, kind, starts_at, ends_at, status, notes, order_id, location_id, metadata, party_id, assignee_id, created_at, updated_at')
174
+ .gte('starts_at', query.dateRange.start)
175
+ .lte('starts_at', query.dateRange.end)
176
+
177
+ if (query.kind) q = q.eq('kind', query.kind)
178
+ if (query.statuses?.length) q = q.in('status', query.statuses)
179
+ if (query.professionalIds?.length) q = q.in('assignee_id', query.professionalIds)
180
+ if (query.locationId) q = q.eq('location_id', query.locationId)
181
+
182
+ const { data, error } = await q.order('starts_at', { ascending: true })
183
+ if (error) throw error
184
+ return hydrateCanonicalBookings(core, data ?? [])
185
+ }
186
+
187
+ async function getCanonicalBookingById(core: any, id: string): Promise<CalendarBooking | null> {
188
+ const columns = 'id, tenant_id, kind, starts_at, ends_at, status, notes, order_id, location_id, metadata, party_id, assignee_id, created_at, updated_at'
189
+ let { data, error } = await core.from('bookings').select(columns).eq('id', id).maybeSingle()
190
+ if (error) throw error
191
+
192
+ if (!data) {
193
+ const fallback = await core.from('bookings').select(columns).eq('order_id', id).maybeSingle()
194
+ if (fallback.error) throw fallback.error
195
+ data = fallback.data
196
+ }
197
+
198
+ if (!data) return null
199
+ const [booking] = await hydrateCanonicalBookings(core, [data])
200
+ return booking ?? null
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Supabase Agenda Provider
205
+ // ---------------------------------------------------------------------------
206
+
207
+ export function createSupabaseAgendaProvider(): AgendaDataProvider {
208
+ return {
209
+ // v_bookings is in public schema (PostgREST default)
210
+ async getBookings(query: BookingQuery): Promise<CalendarBooking[]> {
211
+ const { core, pub } = getClients()
212
+ let q = pub.from('v_bookings').select('*')
213
+ .gte('starts_at', query.dateRange.start)
214
+ .lte('starts_at', query.dateRange.end)
215
+
216
+ if (query.kind) q = q.eq('kind', query.kind)
217
+ if (query.statuses?.length) q = q.in('status', query.statuses)
218
+ if (query.professionalIds?.length) q = q.in('professional_id', query.professionalIds)
219
+ if (query.locationId) q = q.eq('location_id', query.locationId)
220
+
221
+ q = q.order('starts_at', { ascending: true })
222
+
223
+ const { data, error } = await q
224
+ if (error) throw error
225
+ const viewBookings = (data ?? []).map(mapBooking)
226
+ const canonicalBookings = await getCanonicalBookings(core, query)
227
+ return mergeBookings(viewBookings, canonicalBookings)
228
+ },
229
+
230
+ async getBookingById(id: string): Promise<CalendarBooking | null> {
231
+ const { core, pub } = getClients()
232
+ const { data, error } = await pub.from('v_bookings').select('*').eq('id', id).maybeSingle()
233
+ if (error) throw error
234
+ if (data) return mapBooking(data as Record<string, unknown>)
235
+ return getCanonicalBookingById(core, id)
236
+ },
237
+
238
+ async createBooking(input: CreateBookingInput): Promise<CalendarBooking> {
239
+ const { core } = getClients()
240
+ const tenantId = getTenantId()
241
+ if (!tenantId) throw new Error('No active tenant')
242
+
243
+ const hasServices = input.services.length > 0
244
+ const totalDuration = input.services.reduce((sum, s) => sum + s.durationMinutes, 0)
245
+ const totalPrice = input.services.reduce((sum, s) => sum + s.price, 0)
246
+ const endsAt = addMinutes(input.startsAt, totalDuration || 30)
247
+ const serviceNames = input.services.map((s: any) => s.name).join(', ')
248
+
249
+ // Resolve client name for metadata (used by financial detail view)
250
+ let contactName: string | undefined
251
+ if (input.clientId) {
252
+ const { data: person } = await core.from('persons').select('name').eq('id', input.clientId).single()
253
+ contactName = person?.name as string | undefined
254
+ }
255
+
256
+ const { data: order, error: orderErr } = await core.from('orders').insert({
257
+ tenant_id: tenantId,
258
+ kind: input.kind ?? 'appointment',
259
+ status: 'scheduled',
260
+ party_id: input.clientId || null,
261
+ assignee_id: input.professionalId,
262
+ location_id: input.locationId ?? null,
263
+ subtotal: totalPrice,
264
+ total: totalPrice,
265
+ notes: input.notes ?? null,
266
+ metadata: {
267
+ source: 'agenda',
268
+ ...(serviceNames ? { serviceNames, itemsSummary: serviceNames } : {}),
269
+ ...(contactName ? { contactName } : {}),
270
+ },
271
+ }).select('id').single()
272
+ if (orderErr) throw orderErr
273
+
274
+ const { data: booking, error: bookingErr } = await core.from('bookings').insert({
275
+ tenant_id: tenantId,
276
+ kind: input.kind ?? 'appointment',
277
+ party_id: input.clientId || null,
278
+ assignee_id: input.professionalId,
279
+ location_id: input.locationId ?? null,
280
+ order_id: order.id,
281
+ starts_at: input.startsAt,
282
+ ends_at: endsAt,
283
+ status: 'scheduled',
284
+ notes: input.notes ?? null,
285
+ metadata: {
286
+ source: 'agenda',
287
+ ...(serviceNames ? { serviceNames, itemsSummary: serviceNames } : {}),
288
+ ...(contactName ? { contactName } : {}),
289
+ },
290
+ }).select('id').single()
291
+ if (bookingErr) throw bookingErr
292
+
293
+ if (hasServices) {
294
+ // S4: line items live ONLY in order_items now (booking_items deprecated).
295
+ const orderItems = input.services.map((s, i) => ({
296
+ order_id: order.id,
297
+ service_id: s.serviceId,
298
+ name: s.name,
299
+ quantity: 1,
300
+ unit_price: s.price,
301
+ total: s.price,
302
+ sort_order: i,
303
+ duration_minutes: s.durationMinutes,
304
+ assignee_id: s.assigneeId ?? input.professionalId,
305
+ }))
306
+ const { error: oiErr } = await core.from('order_items').insert(orderItems)
307
+ if (oiErr) throw oiErr
308
+ }
309
+
310
+ const createdBooking = await this.getBookingById(booking.id)
311
+ if (!createdBooking) throw new Error('Created booking did not resolve from agenda read model')
312
+ return createdBooking
313
+ },
314
+
315
+ async updateBooking(id: string, data: UpdateBookingInput): Promise<CalendarBooking> {
316
+ const { core } = getClients()
317
+ const { data: existing, error: existingErr } = await core.from('bookings')
318
+ .select('id, order_id, starts_at, assignee_id')
319
+ .eq('id', id)
320
+ .single()
321
+ if (existingErr) throw existingErr
322
+
323
+ const updates: Record<string, unknown> = {}
324
+ if (data.startsAt) updates.starts_at = data.startsAt
325
+ if (data.endsAt) updates.ends_at = data.endsAt
326
+ if (data.status) {
327
+ // Only update the agenda status — never touch the financial stage
328
+ updates.status = data.status
329
+ }
330
+ if (data.notes !== undefined) updates.notes = data.notes
331
+ if (data.professionalId) updates.assignee_id = data.professionalId
332
+ if (data.clientId) updates.party_id = data.clientId
333
+ if (data.locationId) updates.location_id = data.locationId
334
+
335
+ // Recalculate totals when services are updated
336
+ if (data.services) {
337
+ const totalDuration = data.services.reduce((s, svc) => s + svc.durationMinutes, 0)
338
+ const totalPrice = data.services.reduce((s, svc) => s + svc.price, 0)
339
+ // Recalculate ends_at from startsAt + total duration
340
+ const startsAt = data.startsAt ?? (existing?.starts_at as string | undefined)
341
+ if (startsAt) {
342
+ const ends = new Date(new Date(startsAt).getTime() + totalDuration * 60000)
343
+ updates.ends_at = ends.toISOString()
344
+ }
345
+ }
346
+
347
+ const effectiveAssigneeId = data.professionalId ?? (existing?.assignee_id as string | null | undefined) ?? null
348
+
349
+ const { error } = await core.from('bookings').update(updates).eq('id', id)
350
+ if (error) throw error
351
+
352
+ // S4: services are updated only in order_items below (booking_items deprecated).
353
+ const orderId = existing?.order_id as string | null | undefined
354
+ if (orderId) {
355
+ const orderUpdates: Record<string, unknown> = {}
356
+ if (data.notes !== undefined) orderUpdates.notes = data.notes
357
+ if (data.professionalId) orderUpdates.assignee_id = data.professionalId
358
+ if (data.clientId) orderUpdates.party_id = data.clientId
359
+ if (data.locationId) orderUpdates.location_id = data.locationId
360
+
361
+ if (data.services) {
362
+ const totalPrice = data.services.reduce((s, svc) => s + svc.price, 0)
363
+ orderUpdates.subtotal = totalPrice
364
+ orderUpdates.total = totalPrice
365
+ }
366
+
367
+ if (Object.keys(orderUpdates).length > 0) {
368
+ const { error: orderErr } = await core.from('orders').update(orderUpdates).eq('id', orderId)
369
+ if (orderErr) throw orderErr
370
+ }
371
+
372
+ if (data.services) {
373
+ await core.from('order_items').delete().eq('order_id', orderId)
374
+ if (data.services.length > 0) {
375
+ const items = data.services.map((svc, idx) => ({
376
+ order_id: orderId,
377
+ service_id: svc.serviceId || null,
378
+ name: svc.name,
379
+ quantity: 1,
380
+ unit_price: svc.price,
381
+ total: svc.price,
382
+ sort_order: idx,
383
+ duration_minutes: svc.durationMinutes,
384
+ assignee_id: svc.assigneeId ?? effectiveAssigneeId,
385
+ }))
386
+ const { error: itemErr } = await core.from('order_items').insert(items)
387
+ if (itemErr) throw itemErr
388
+ }
389
+ }
390
+ }
391
+
392
+ return (await this.getBookingById(id))!
393
+ },
394
+
395
+ async deleteBooking(id: string): Promise<void> {
396
+ const { core, pub } = getClients()
397
+ const { data: booking, error: bookingErr } = await core.from('bookings')
398
+ .select('order_id')
399
+ .eq('id', id)
400
+ .single()
401
+ if (bookingErr) throw bookingErr
402
+
403
+ const orderId = booking?.order_id as string | null | undefined
404
+ const { count, error: movementErr } = orderId
405
+ ? await pub.from('financial_movements').select('id', { count: 'exact', head: true }).eq('invoice_id', orderId)
406
+ : { count: 0, error: null }
407
+ if (movementErr) throw movementErr
408
+
409
+ if (orderId && (count ?? 0) > 0) {
410
+ // Cancel movements + order (don't delete financial records)
411
+ await pub.from('financial_movements').update({ status: 'cancelled' }).eq('invoice_id', orderId)
412
+ await core.from('orders').update({ status: 'cancelled' }).eq('id', orderId)
413
+ const { error } = await core.from('bookings').update({ status: 'cancelled' }).eq('id', id)
414
+ if (error) throw error
415
+ } else {
416
+ // Safe to delete — remove any movements first (FK constraint)
417
+ if (orderId) {
418
+ await pub.from('financial_movements').delete().eq('invoice_id', orderId)
419
+ }
420
+ const { error } = await core.from('bookings').delete().eq('id', id)
421
+ if (error) throw error
422
+ if (orderId) {
423
+ const { error: orderErr } = await core.from('orders').delete().eq('id', orderId)
424
+ if (orderErr) throw orderErr
425
+ }
426
+ }
427
+ },
428
+
429
+ async updateBookingStatus(id: string, status: string): Promise<CalendarBooking> {
430
+ return this.updateBooking(id, { status: status as BookingStatus })
431
+ },
432
+
433
+ async checkConflict(params: ConflictCheckParams): Promise<boolean> {
434
+ const { core } = getClients()
435
+ let q = core.from('bookings').select('id', { count: 'exact', head: true })
436
+ .eq('assignee_id', params.assigneeId)
437
+ .eq('kind', 'appointment')
438
+ .not('status', 'in', '("cancelled","no_show")')
439
+ .not('starts_at', 'is', null)
440
+ .lt('starts_at', params.endsAt)
441
+ .gt('ends_at', params.startsAt)
442
+
443
+ if (params.excludeBookingId) {
444
+ q = q.neq('id', params.excludeBookingId)
445
+ }
446
+
447
+ const { count, error } = await q
448
+ if (error) throw error
449
+ return (count ?? 0) > 0
450
+ },
451
+
452
+ async getAvailableSlots(params: AvailableSlotsParams): Promise<TimeSlot[]> {
453
+ const { pub } = getClients()
454
+ const { data, error } = await pub.rpc('get_available_slots', {
455
+ p_tenant_id: getTenantId(),
456
+ p_assignee_id: params.assigneeId,
457
+ p_date: params.date,
458
+ p_duration_minutes: params.durationMinutes,
459
+ p_slot_interval: params.slotInterval ?? 30,
460
+ })
461
+ if (error) throw error
462
+ return (data ?? []).map((row: any) => ({ start: row.slot_start, end: row.slot_end }))
463
+ },
464
+
465
+ async getSchedules(professionalId?: string): Promise<Schedule[]> {
466
+ const { core } = getClients()
467
+ let q = core.from('schedules').select('*').eq('kind', 'working_hours')
468
+ if (professionalId) q = q.eq('assignee_id', professionalId)
469
+ q = q.order('day_of_week').order('starts_at')
470
+
471
+ const { data, error } = await q
472
+ if (error) throw error
473
+ return (data ?? []).map(mapSchedule)
474
+ },
475
+
476
+ async saveSchedule(input: SaveScheduleInput): Promise<Schedule> {
477
+ const { core } = getClients()
478
+ const tenantId = getTenantId()
479
+ if (!tenantId) throw new Error('No active tenant')
480
+
481
+ const { data, error } = await core.from('schedules').insert({
482
+ tenant_id: tenantId,
483
+ kind: 'working_hours',
484
+ assignee_id: input.assigneeId,
485
+ location_id: input.locationId ?? null,
486
+ day_of_week: input.dayOfWeek ?? null,
487
+ specific_date: input.specificDate ?? null,
488
+ starts_at: input.startsAt,
489
+ ends_at: input.endsAt,
490
+ is_active: input.isActive !== false,
491
+ metadata: input.metadata ?? {},
492
+ }).select('*').single()
493
+ if (error) throw error
494
+ return mapSchedule(data as Record<string, unknown>)
495
+ },
496
+
497
+ async deleteSchedule(id: string): Promise<void> {
498
+ const { core } = getClients()
499
+ const { error } = await core.from('schedules').delete().eq('id', id)
500
+ if (error) throw error
501
+ },
502
+
503
+ async getProfessionals(): Promise<Professional[]> {
504
+ const { core } = getClients()
505
+ const { data, error } = await core.from('persons')
506
+ .select('id, name, avatar_url, is_active')
507
+ .eq('kind', 'staff')
508
+ .eq('is_active', true)
509
+ .order('name')
510
+ if (error) throw error
511
+ return (data ?? []).map((p: any) => ({
512
+ id: p.id, name: p.name, avatarUrl: p.avatar_url,
513
+ locationId: null, locationName: null, isActive: p.is_active,
514
+ }))
515
+ },
516
+
517
+ async getLocations(): Promise<Array<{ id: string; name: string }>> {
518
+ const { core } = getClients()
519
+ const { data, error } = await core.from('locations')
520
+ .select('id, name')
521
+ .eq('is_active', true)
522
+ .order('name')
523
+ if (error) throw error
524
+ return (data ?? []).map((l: any) => ({ id: l.id, name: l.name }))
525
+ },
526
+
527
+ async getConfirmationsPending(daysAhead = 2): Promise<CalendarBooking[]> {
528
+ const { pub } = getClients()
529
+ const now = new Date().toISOString()
530
+ const cutoff = new Date()
531
+ cutoff.setDate(cutoff.getDate() + daysAhead)
532
+
533
+ const { data, error } = await pub.from('v_bookings').select('*')
534
+ .eq('status', 'scheduled')
535
+ .gte('starts_at', now)
536
+ .lte('starts_at', cutoff.toISOString())
537
+ .order('starts_at')
538
+ if (error) throw error
539
+ return (data ?? []).map(mapBooking)
540
+ },
541
+
542
+ async sendConfirmation(bookingId: string, channel: string): Promise<void> {
543
+ const { core } = getClients()
544
+ const { data: booking } = await core.from('bookings').select('metadata').eq('id', bookingId).single()
545
+ const metadata = (booking?.metadata ?? {}) as Record<string, unknown>
546
+ const confirmations = (metadata.confirmations as any[] ?? [])
547
+ confirmations.push({ channel, sentAt: new Date().toISOString(), status: 'sent' })
548
+ await core.from('bookings').update({ metadata: { ...metadata, confirmations } }).eq('id', bookingId)
549
+ },
550
+
551
+ async completeBooking(bookingId: string): Promise<{ booking: CalendarBooking; orderId: string }> {
552
+ const { core } = getClients()
553
+ const { data: existing, error: existingErr } = await core.from('bookings')
554
+ .select('order_id')
555
+ .eq('id', bookingId)
556
+ .single()
557
+ if (existingErr) throw existingErr
558
+
559
+ const { error } = await core.from('bookings')
560
+ .update({ status: 'completed' })
561
+ .eq('id', bookingId)
562
+ if (error) throw error
563
+
564
+ const orderId = existing?.order_id as string | null | undefined
565
+ const booking = await this.getBookingById(bookingId)
566
+ if (!booking) throw new Error('Booking not found')
567
+ return { booking, orderId: orderId ?? bookingId }
568
+ },
569
+ }
570
+ }
@@ -0,0 +1,37 @@
1
+ import type {
2
+ CalendarBooking, Professional, Schedule, TimeSlot,
3
+ CreateBookingInput, UpdateBookingInput, SaveScheduleInput,
4
+ BookingQuery, ConflictCheckParams, AvailableSlotsParams,
5
+ } from '../types'
6
+
7
+ export interface AgendaDataProvider {
8
+ // --- Bookings (calendar events) ---
9
+ getBookings(query: BookingQuery): Promise<CalendarBooking[]>
10
+ getBookingById(id: string): Promise<CalendarBooking | null>
11
+ createBooking(input: CreateBookingInput): Promise<CalendarBooking>
12
+ updateBooking(id: string, data: UpdateBookingInput): Promise<CalendarBooking>
13
+ deleteBooking(id: string): Promise<void>
14
+ updateBookingStatus(id: string, status: string): Promise<CalendarBooking>
15
+
16
+ // --- Conflict detection ---
17
+ checkConflict(params: ConflictCheckParams): Promise<boolean>
18
+ getAvailableSlots(params: AvailableSlotsParams): Promise<TimeSlot[]>
19
+
20
+ // --- Schedules (working hours) ---
21
+ getSchedules(professionalId?: string): Promise<Schedule[]>
22
+ saveSchedule(input: SaveScheduleInput): Promise<Schedule>
23
+ deleteSchedule(id: string): Promise<void>
24
+
25
+ // --- Professionals (resources) ---
26
+ getProfessionals(): Promise<Professional[]>
27
+
28
+ // --- Locations ---
29
+ getLocations?(): Promise<Array<{ id: string; name: string }>>
30
+
31
+ // --- Confirmations ---
32
+ getConfirmationsPending(daysAhead?: number): Promise<CalendarBooking[]>
33
+ sendConfirmation(bookingId: string, channel: string): Promise<void>
34
+
35
+ // --- Financial bridge ---
36
+ completeBooking(bookingId: string): Promise<{ booking: CalendarBooking; orderId: string }>
37
+ }