@fayz-ai/plugin-agenda 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) 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-76ZOHRBM.js} +2 -2
  6. package/dist/{chunk-Q2PAWBPR.js.map → chunk-76ZOHRBM.js.map} +1 -1
  7. package/dist/components/PaymentStatusBadge.d.ts +1 -1
  8. package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/data/registries.d.ts.map +1 -0
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +170 -7
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/message-events.d.ts +12 -0
  19. package/dist/lib/message-events.d.ts.map +1 -0
  20. package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
  21. package/dist/public/data/index.d.ts.map +1 -0
  22. package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
  23. package/dist/public/data/supabase.d.ts.map +1 -0
  24. package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
  25. package/dist/public/hooks/index.d.ts.map +1 -0
  26. package/dist/public/index.d.ts +4 -4
  27. package/dist/public/index.d.ts.map +1 -1
  28. package/dist/public/index.js +3 -3
  29. package/dist/public/index.js.map +1 -1
  30. package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
  31. package/dist/public/lib/format.d.ts.map +1 -0
  32. package/dist/public/views/BookingWidget.d.ts.map +1 -0
  33. package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
  34. package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
  35. package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
  36. package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
  37. package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
  38. package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
  39. package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
  40. package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
  41. package/dist/store.d.ts +1 -1
  42. package/dist/store.d.ts.map +1 -1
  43. package/dist/styles/calendar-styles.d.ts.map +1 -0
  44. package/dist/types.d.ts +1 -1
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
  47. package/dist/views/AgendaPage.d.ts.map +1 -0
  48. package/package.json +6 -9
  49. package/dist/AgendaContext.d.ts.map +0 -1
  50. package/dist/AgendaPage.d.ts.map +0 -1
  51. package/dist/calendar-styles.d.ts.map +0 -1
  52. package/dist/financial-bridge.d.ts.map +0 -1
  53. package/dist/public/BookingWidget.d.ts.map +0 -1
  54. package/dist/public/data.d.ts.map +0 -1
  55. package/dist/public/data.supabase.d.ts.map +0 -1
  56. package/dist/public/format.d.ts.map +0 -1
  57. package/dist/public/hooks.d.ts.map +0 -1
  58. package/dist/public/steps/ContactStep.d.ts.map +0 -1
  59. package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
  60. package/dist/public/steps/OverviewStep.d.ts.map +0 -1
  61. package/dist/public/steps/PaymentStep.d.ts.map +0 -1
  62. package/dist/registries.d.ts.map +0 -1
  63. package/src/AgendaContext.tsx +0 -11
  64. package/src/AgendaPage.tsx +0 -33
  65. package/src/bridges/create-financial-bridge.ts +0 -296
  66. package/src/calendar-styles.css +0 -245
  67. package/src/calendar-styles.ts +0 -285
  68. package/src/components/AgendaGeneralSettings.tsx +0 -86
  69. package/src/components/AgendaMobileList.tsx +0 -103
  70. package/src/components/AppointmentModal.tsx +0 -1050
  71. package/src/components/AppointmentPopover.tsx +0 -231
  72. package/src/components/BookingPaymentPanel.tsx +0 -394
  73. package/src/components/CalendarSidebar.tsx +0 -119
  74. package/src/components/CalendarToolbar.tsx +0 -86
  75. package/src/components/EventContextMenu.tsx +0 -125
  76. package/src/components/FloatingPanel.tsx +0 -123
  77. package/src/components/MiniCalendar.tsx +0 -120
  78. package/src/components/PaymentStatusBadge.tsx +0 -62
  79. package/src/components/StatusBadge.tsx +0 -23
  80. package/src/config.ts +0 -368
  81. package/src/data/index.ts +0 -4
  82. package/src/data/mock.ts +0 -344
  83. package/src/data/supabase.ts +0 -636
  84. package/src/data/types.ts +0 -37
  85. package/src/financial-bridge.ts +0 -96
  86. package/src/hooks/useAgendaSettings.ts +0 -17
  87. package/src/index.ts +0 -297
  88. package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
  89. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
  90. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
  91. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
  92. package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
  93. package/src/integrations/google-calendar/connector.ts +0 -20
  94. package/src/integrations/google-calendar/connectorDef.tsx +0 -185
  95. package/src/integrations/google-calendar/data/supabase.ts +0 -170
  96. package/src/integrations/google-calendar/data/tables.ts +0 -12
  97. package/src/integrations/google-calendar/index.ts +0 -45
  98. package/src/integrations/google-calendar/integration.test.ts +0 -199
  99. package/src/integrations/google-calendar/mapping.ts +0 -183
  100. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
  101. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
  102. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
  103. package/src/integrations/google-calendar/types.ts +0 -142
  104. package/src/lib/tenant.ts +0 -3
  105. package/src/locales/en.ts +0 -220
  106. package/src/locales/index.ts +0 -7
  107. package/src/locales/pt-BR.ts +0 -220
  108. package/src/migrations/000b_v_bookings_rename.sql +0 -21
  109. package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
  110. package/src/migrations/001_public_booking.sql +0 -296
  111. package/src/migrations/002_v_bookings_compat.sql +0 -14
  112. package/src/migrations/003_public_client_kind.sql +0 -175
  113. package/src/migrations/004_public_booking_assignee.sql +0 -176
  114. package/src/migrations/005_agent_rpcs.sql +0 -168
  115. package/src/migrations/006_agent_rpc_localtime.sql +0 -181
  116. package/src/migrations/007_schedule_exception_precedence.sql +0 -290
  117. package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
  118. package/src/migrations/009_public_booking_location.sql +0 -162
  119. package/src/migrations/010_public_service_curation.sql +0 -31
  120. package/src/migrations/011_default_assignee.sql +0 -167
  121. package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
  122. package/src/migrations/013_normalize_v_appointments.sql +0 -199
  123. package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
  124. package/src/migrations/index.ts +0 -2377
  125. package/src/public/BookingWidget.tsx +0 -324
  126. package/src/public/components/BrandHeader.tsx +0 -27
  127. package/src/public/components/ServiceCard.tsx +0 -39
  128. package/src/public/components/SidebarExtras.tsx +0 -30
  129. package/src/public/components/StepRailItem.tsx +0 -27
  130. package/src/public/components/SuccessPanel.tsx +0 -27
  131. package/src/public/components/SummaryRail.tsx +0 -90
  132. package/src/public/components/defaults.ts +0 -14
  133. package/src/public/context.tsx +0 -103
  134. package/src/public/createPublicBookingPlugin.ts +0 -281
  135. package/src/public/data.supabase.ts +0 -97
  136. package/src/public/data.ts +0 -105
  137. package/src/public/format.ts +0 -96
  138. package/src/public/hooks.ts +0 -110
  139. package/src/public/index.ts +0 -69
  140. package/src/public/steps/ContactStep.tsx +0 -184
  141. package/src/public/steps/DateTimeStep.tsx +0 -138
  142. package/src/public/steps/OverviewStep.tsx +0 -81
  143. package/src/public/steps/PaymentStep.tsx +0 -180
  144. package/src/public/types.ts +0 -250
  145. package/src/registries.ts +0 -35
  146. package/src/store.ts +0 -311
  147. package/src/types.ts +0 -273
  148. package/src/views/CalendarView.tsx +0 -1112
  149. package/src/views/ConfirmationsView.tsx +0 -155
  150. /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
  151. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
  152. /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
  153. /package/dist/{calendar-styles.d.ts → styles/calendar-styles.d.ts} +0 -0
@@ -1,636 +0,0 @@
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
- // Core v1 lives in PUBLIC — a single client serves both core and view reads.
32
- return { core: supabase, pub: supabase }
33
- }
34
-
35
- function mapBooking(row: Record<string, unknown>): CalendarBooking {
36
- return snakeToCamel(row) as unknown as CalendarBooking
37
- }
38
-
39
- function mapSchedule(row: Record<string, unknown>): Schedule {
40
- return snakeToCamel(row) as unknown as Schedule
41
- }
42
-
43
- function addMinutes(iso: string, minutes: number): string {
44
- const d = new Date(iso)
45
- d.setMinutes(d.getMinutes() + minutes)
46
- return d.toISOString()
47
- }
48
-
49
- function unique(values: Array<string | null | undefined>): string[] {
50
- return Array.from(new Set(values.filter((value): value is string => !!value)))
51
- }
52
-
53
- function indexById(rows: any[] | null | undefined): Map<string, any> {
54
- return new Map((rows ?? []).filter((row) => row?.id).map((row) => [row.id, row]))
55
- }
56
-
57
- function groupBy<T>(rows: T[], getKey: (row: T) => string | null | undefined): Map<string, T[]> {
58
- const grouped = new Map<string, T[]>()
59
- for (const row of rows) {
60
- const key = getKey(row)
61
- if (!key) continue
62
- grouped.set(key, [...(grouped.get(key) ?? []), row])
63
- }
64
- return grouped
65
- }
66
-
67
- function getDurationMinutes(startsAt: string, endsAt: string | null | undefined): number | null {
68
- if (!endsAt) return null
69
- return Math.round((new Date(endsAt).getTime() - new Date(startsAt).getTime()) / 60000)
70
- }
71
-
72
- function mergeBookings(viewBookings: CalendarBooking[], canonicalBookings: CalendarBooking[]): CalendarBooking[] {
73
- const canonicalIds = new Set(canonicalBookings.map((booking) => booking.id))
74
- const canonicalOrderIds = new Set(canonicalBookings.map((booking) => booking.orderId).filter(Boolean))
75
- const merged = [
76
- ...canonicalBookings,
77
- ...viewBookings.filter((booking) => {
78
- if (canonicalIds.has(booking.id)) return false
79
- if (booking.orderId && canonicalOrderIds.has(booking.orderId)) return false
80
- if (canonicalOrderIds.has(booking.id)) return false
81
- return true
82
- }),
83
- ]
84
-
85
- return merged.sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime())
86
- }
87
-
88
- async function hydrateCanonicalBookings(core: any, bookings: any[]): Promise<CalendarBooking[]> {
89
- const personIds = unique(bookings.flatMap((booking) => [booking.party_id, booking.assignee_id]))
90
- const locationIds = unique(bookings.map((booking) => booking.location_id))
91
- const orderIds = unique(bookings.map((booking) => booking.order_id))
92
- const bookingIds = unique(bookings.map((booking) => booking.id))
93
-
94
- const [
95
- { data: persons, error: personsError },
96
- { data: locations, error: locationsError },
97
- { data: orders, error: ordersError },
98
- { data: items, error: itemsError },
99
- ] = await Promise.all([
100
- personIds.length
101
- ? core.from('people').select('id, name, phone, email, avatar_url').in('id', personIds)
102
- : Promise.resolve({ data: [], error: null }),
103
- locationIds.length
104
- ? core.from('locations').select('id, name').in('id', locationIds)
105
- : Promise.resolve({ data: [], error: null }),
106
- orderIds.length
107
- ? core.from('orders').select('id, total, status').in('id', orderIds)
108
- : Promise.resolve({ data: [], error: null }),
109
- orderIds.length
110
- ? core.from('order_items').select('id, order_id, service_id, name, duration_minutes, unit_price, assignee_id, sort_order').in('order_id', orderIds)
111
- : Promise.resolve({ data: [], error: null }),
112
- ])
113
-
114
- if (personsError) throw personsError
115
- if (locationsError) throw locationsError
116
- if (ordersError) throw ordersError
117
- if (itemsError) throw itemsError
118
-
119
- const personsById = indexById(persons)
120
- const locationsById = indexById(locations)
121
- const ordersById = indexById(orders)
122
- // S4: line items come from order_items (single source), keyed by order_id.
123
- const itemsByOrderId = groupBy(items ?? [], (item: any) => item.order_id)
124
-
125
- return bookings.map((booking) => {
126
- const client = booking.party_id ? personsById.get(booking.party_id) : null
127
- const professional = booking.assignee_id ? personsById.get(booking.assignee_id) : null
128
- const location = booking.location_id ? locationsById.get(booking.location_id) : null
129
- const order = booking.order_id ? ordersById.get(booking.order_id) : null
130
- const bookingItems = ((booking.order_id ? itemsByOrderId.get(booking.order_id) : null) ?? [])
131
- .sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
132
- .map((item: any) => ({
133
- id: item.id,
134
- serviceId: item.service_id,
135
- name: item.name,
136
- durationMinutes: item.duration_minutes,
137
- price: item.unit_price,
138
- assigneeId: item.assignee_id,
139
- }))
140
- const itemDuration = bookingItems.reduce((sum: number, item: any) => sum + (item.durationMinutes ?? 0), 0)
141
-
142
- return {
143
- id: booking.id,
144
- tenantId: booking.tenant_id,
145
- kind: booking.kind,
146
- startsAt: booking.starts_at,
147
- endsAt: booking.ends_at,
148
- status: booking.status,
149
- notes: booking.notes,
150
- orderId: booking.order_id,
151
- locationId: booking.location_id,
152
- metadata: booking.metadata ?? {},
153
- clientId: booking.party_id,
154
- // Simple events (no party) fall back to the title stored in metadata;
155
- // partyless blocks/tasks (e.g. imported Google events) get their notes or
156
- // a kind label so the calendar never renders an unnamed chip.
157
- clientName: client?.name
158
- ?? (booking.metadata?.title as string | undefined)
159
- ?? (booking.kind === 'appointment'
160
- ? null
161
- : booking.notes || (booking.kind === 'task' ? 'Tarefa' : 'Bloqueio')),
162
- clientPhone: client?.phone ?? null,
163
- clientEmail: client?.email ?? null,
164
- clientAvatarUrl: client?.avatar_url ?? null,
165
- professionalId: booking.assignee_id,
166
- professionalName: professional?.name ?? null,
167
- professionalAvatarUrl: professional?.avatar_url ?? null,
168
- locationName: location?.name ?? null,
169
- orderTotal: order?.total ?? null,
170
- orderStatus: order?.status ?? null,
171
- services: bookingItems.length ? bookingItems : null,
172
- totalDurationMinutes: itemDuration || getDurationMinutes(booking.starts_at, booking.ends_at),
173
- createdAt: booking.created_at,
174
- updatedAt: booking.updated_at,
175
- } satisfies CalendarBooking
176
- })
177
- }
178
-
179
- async function getCanonicalBookings(core: any, query: BookingQuery): Promise<CalendarBooking[]> {
180
- let q = core.from('appointments')
181
- .select('id, tenant_id, kind, starts_at, ends_at, status, notes, order_id, location_id, metadata, party_id, assignee_id, created_at, updated_at')
182
- .gte('starts_at', query.dateRange.start)
183
- .lte('starts_at', query.dateRange.end)
184
-
185
- if (query.kind) q = q.eq('kind', query.kind)
186
- if (query.statuses?.length) q = q.in('status', query.statuses)
187
- if (query.professionalIds?.length) q = q.in('assignee_id', query.professionalIds)
188
- if (query.locationId) q = q.eq('location_id', query.locationId)
189
-
190
- const { data, error } = await q.order('starts_at', { ascending: true })
191
- if (error) throw error
192
- return hydrateCanonicalBookings(core, data ?? [])
193
- }
194
-
195
- async function getCanonicalBookingById(core: any, id: string): Promise<CalendarBooking | null> {
196
- 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'
197
- let { data, error } = await core.from('appointments').select(columns).eq('id', id).maybeSingle()
198
- if (error) throw error
199
-
200
- if (!data) {
201
- const fallback = await core.from('appointments').select(columns).eq('order_id', id).maybeSingle()
202
- if (fallback.error) throw fallback.error
203
- data = fallback.data
204
- }
205
-
206
- if (!data) return null
207
- const [booking] = await hydrateCanonicalBookings(core, [data])
208
- return booking ?? null
209
- }
210
-
211
- // ---------------------------------------------------------------------------
212
- // Supabase Agenda Provider
213
- // ---------------------------------------------------------------------------
214
-
215
- export interface SupabaseAgendaProviderOptions {
216
- /** people.kind used for calendar resources/professionals (default 'staff' — schools use 'teacher'). */
217
- professionalKind?: string
218
- }
219
-
220
- export function createSupabaseAgendaProvider(providerOptions?: SupabaseAgendaProviderOptions): AgendaDataProvider {
221
- const professionalKind = providerOptions?.professionalKind ?? 'staff'
222
- return {
223
- // v_appointments is in public schema (PostgREST default)
224
- async getBookings(query: BookingQuery): Promise<CalendarBooking[]> {
225
- const { core, pub } = getClients()
226
- let q = pub.from('v_appointments').select('*')
227
- .gte('starts_at', query.dateRange.start)
228
- .lte('starts_at', query.dateRange.end)
229
-
230
- if (query.kind) q = q.eq('kind', query.kind)
231
- if (query.statuses?.length) q = q.in('status', query.statuses)
232
- if (query.professionalIds?.length) q = q.in('professional_id', query.professionalIds)
233
- if (query.locationId) q = q.eq('location_id', query.locationId)
234
-
235
- q = q.order('starts_at', { ascending: true })
236
-
237
- const { data, error } = await q
238
- if (error) throw error
239
- const viewBookings = (data ?? []).map(mapBooking)
240
- const canonicalBookings = await getCanonicalBookings(core, query)
241
- return mergeBookings(viewBookings, canonicalBookings)
242
- },
243
-
244
- async getBookingById(id: string): Promise<CalendarBooking | null> {
245
- const { core, pub } = getClients()
246
- const { data, error } = await pub.from('v_appointments').select('*').eq('id', id).maybeSingle()
247
- if (error) throw error
248
- if (data) {
249
- const booking = mapBooking(data as Record<string, unknown>)
250
- // v_appointments is a FLAT read model — it exposes no line items. The
251
- // calendar list path merges in the canonical hydration (which carries
252
- // `services` from order_items), but this single-record lookup used by the
253
- // edit modal previously returned the bare view row. That dropped
254
- // `services`, so a booking whose type has `requiresServices` reopened with
255
- // an empty service list and kept the "Update" button permanently disabled
256
- // (canSubmit stayed false). Hydrate the line items here to stay consistent
257
- // with getBookings.
258
- if (booking.orderId) {
259
- const { data: items } = await core.from('order_items')
260
- .select('id, service_id, name, duration_minutes, unit_price, assignee_id, sort_order')
261
- .eq('order_id', booking.orderId)
262
- .order('sort_order', { ascending: true })
263
- if (items && items.length) {
264
- const services = items.map((item: any) => ({
265
- id: item.id,
266
- serviceId: item.service_id,
267
- name: item.name,
268
- durationMinutes: item.duration_minutes,
269
- price: item.unit_price,
270
- assigneeId: item.assignee_id,
271
- }))
272
- booking.services = services
273
- const itemDuration = services.reduce((sum: number, s: any) => sum + (s.durationMinutes ?? 0), 0)
274
- if (itemDuration) booking.totalDurationMinutes = itemDuration
275
- }
276
- }
277
- return booking
278
- }
279
- return getCanonicalBookingById(core, id)
280
- },
281
-
282
- async createBooking(input: CreateBookingInput): Promise<CalendarBooking> {
283
- const { core } = getClients()
284
- const tenantId = getTenantId()
285
- if (!tenantId) throw new Error('No active tenant')
286
-
287
- const hasServices = input.services.length > 0
288
- const totalDuration = input.services.reduce((sum, s) => sum + s.durationMinutes, 0)
289
- const totalPrice = input.services.reduce((sum, s) => sum + s.price, 0)
290
- const endsAt = addMinutes(input.startsAt, totalDuration || 30)
291
- const serviceNames = input.services.map((s: any) => s.name).join(', ')
292
-
293
- // Resolve client name for metadata (used by financial detail view)
294
- let contactName: string | undefined
295
- if (input.clientId) {
296
- const { data: person } = await core.from('people').select('name').eq('id', input.clientId).single()
297
- contactName = person?.name as string | undefined
298
- }
299
-
300
- // Simple / Google-Calendar-style events have no client party — persist the
301
- // free-text title in metadata so the read model can surface it as the name.
302
- const eventTitle = input.title?.trim() || undefined
303
-
304
- const { data: order, error: orderErr } = await core.from('orders').insert({
305
- tenant_id: tenantId,
306
- kind: input.kind ?? 'appointment',
307
- status: 'scheduled',
308
- party_id: input.clientId || null,
309
- assignee_id: input.professionalId || null,
310
- location_id: input.locationId ?? null,
311
- subtotal: totalPrice,
312
- total: totalPrice,
313
- notes: input.notes ?? null,
314
- metadata: {
315
- source: 'agenda',
316
- ...(serviceNames ? { serviceNames, itemsSummary: serviceNames } : {}),
317
- ...(contactName ? { contactName } : {}),
318
- ...(eventTitle ? { title: eventTitle } : {}),
319
- },
320
- }).select('id').single()
321
- if (orderErr) throw orderErr
322
-
323
- const { data: booking, error: bookingErr } = await core.from('appointments').insert({
324
- tenant_id: tenantId,
325
- kind: input.kind ?? 'appointment',
326
- party_id: input.clientId || null,
327
- assignee_id: input.professionalId || null,
328
- location_id: input.locationId ?? null,
329
- order_id: order.id,
330
- starts_at: input.startsAt,
331
- ends_at: endsAt,
332
- status: 'scheduled',
333
- notes: input.notes ?? null,
334
- metadata: {
335
- source: 'agenda',
336
- ...(serviceNames ? { serviceNames, itemsSummary: serviceNames } : {}),
337
- ...(contactName ? { contactName } : {}),
338
- ...(eventTitle ? { title: eventTitle } : {}),
339
- },
340
- }).select('id').single()
341
- if (bookingErr) throw bookingErr
342
-
343
- if (hasServices) {
344
- // S4: line items live ONLY in order_items now (booking_items deprecated).
345
- const orderItems = input.services.map((s, i) => ({
346
- order_id: order.id,
347
- service_id: s.serviceId,
348
- name: s.name,
349
- quantity: 1,
350
- unit_price: s.price,
351
- total: s.price,
352
- sort_order: i,
353
- duration_minutes: s.durationMinutes,
354
- assignee_id: s.assigneeId || input.professionalId || null,
355
- }))
356
- const { error: oiErr } = await core.from('order_items').insert(orderItems)
357
- if (oiErr) throw oiErr
358
- }
359
-
360
- const createdBooking = await this.getBookingById(booking.id)
361
- if (!createdBooking) throw new Error('Created booking did not resolve from agenda read model')
362
- return createdBooking
363
- },
364
-
365
- async updateBooking(id: string, data: UpdateBookingInput): Promise<CalendarBooking> {
366
- const { core } = getClients()
367
- const { data: existing, error: existingErr } = await core.from('appointments')
368
- .select('id, order_id, starts_at, assignee_id, metadata')
369
- .eq('id', id)
370
- .single()
371
- if (existingErr) throw existingErr
372
-
373
- const updates: Record<string, unknown> = {}
374
- if (data.startsAt) updates.starts_at = data.startsAt
375
- if (data.endsAt) updates.ends_at = data.endsAt
376
- // Simple-event title lives in metadata (no client party) — merge, don't clobber.
377
- if (data.title !== undefined) {
378
- updates.metadata = { ...(existing?.metadata as Record<string, unknown> ?? {}), title: data.title }
379
- }
380
- if (data.status) {
381
- // Only update the agenda status — never touch the financial stage
382
- updates.status = data.status
383
- }
384
- if (data.notes !== undefined) updates.notes = data.notes
385
- if (data.professionalId) updates.assignee_id = data.professionalId
386
- if (data.clientId) updates.party_id = data.clientId
387
- if (data.locationId) updates.location_id = data.locationId
388
-
389
- // Recalculate totals when services are updated
390
- if (data.services) {
391
- const totalDuration = data.services.reduce((s, svc) => s + svc.durationMinutes, 0)
392
- const totalPrice = data.services.reduce((s, svc) => s + svc.price, 0)
393
- // Recalculate ends_at from startsAt + total duration
394
- const startsAt = data.startsAt ?? (existing?.starts_at as string | undefined)
395
- if (startsAt) {
396
- const ends = new Date(new Date(startsAt).getTime() + totalDuration * 60000)
397
- updates.ends_at = ends.toISOString()
398
- }
399
- }
400
-
401
- const effectiveAssigneeId = data.professionalId ?? (existing?.assignee_id as string | null | undefined) ?? null
402
-
403
- const { error } = await core.from('appointments').update(updates).eq('id', id)
404
- if (error) throw error
405
-
406
- // S4: services are updated only in order_items below (booking_items deprecated).
407
- const orderId = existing?.order_id as string | null | undefined
408
- if (orderId) {
409
- const orderUpdates: Record<string, unknown> = {}
410
- if (data.notes !== undefined) orderUpdates.notes = data.notes
411
- if (data.professionalId) orderUpdates.assignee_id = data.professionalId
412
- if (data.clientId) orderUpdates.party_id = data.clientId
413
- if (data.locationId) orderUpdates.location_id = data.locationId
414
-
415
- if (data.services) {
416
- const totalPrice = data.services.reduce((s, svc) => s + svc.price, 0)
417
- orderUpdates.subtotal = totalPrice
418
- orderUpdates.total = totalPrice
419
- }
420
-
421
- if (Object.keys(orderUpdates).length > 0) {
422
- const { error: orderErr } = await core.from('orders').update(orderUpdates).eq('id', orderId)
423
- if (orderErr) throw orderErr
424
- }
425
-
426
- if (data.services) {
427
- await core.from('order_items').delete().eq('order_id', orderId)
428
- if (data.services.length > 0) {
429
- const items = data.services.map((svc, idx) => ({
430
- order_id: orderId,
431
- service_id: svc.serviceId || null,
432
- name: svc.name,
433
- quantity: 1,
434
- unit_price: svc.price,
435
- total: svc.price,
436
- sort_order: idx,
437
- duration_minutes: svc.durationMinutes,
438
- assignee_id: svc.assigneeId ?? effectiveAssigneeId,
439
- }))
440
- const { error: itemErr } = await core.from('order_items').insert(items)
441
- if (itemErr) throw itemErr
442
- }
443
- }
444
- }
445
-
446
- return (await this.getBookingById(id))!
447
- },
448
-
449
- async deleteBooking(id: string): Promise<void> {
450
- const { core, pub } = getClients()
451
- const { data: booking, error: bookingErr } = await core.from('appointments')
452
- .select('order_id')
453
- .eq('id', id)
454
- .single()
455
- if (bookingErr) throw bookingErr
456
-
457
- const orderId = booking?.order_id as string | null | undefined
458
- // Financial movements live in the financial plugin's tables. Pools that
459
- // don't provision that plugin (e.g. school/event agendas) have no
460
- // plg_financial_movements table, and the probe below returns a PostgREST
461
- // "relation does not exist" error. Previously that error was rethrown,
462
- // which aborted the ENTIRE delete — the modal caught it and left the
463
- // booking in place, so "Delete" looked like a silent no-op (works in
464
- // beauty, which has the table; fails in school, which doesn't). Treat any
465
- // probe failure as "no linked movements" and fall through to a plain delete.
466
- let movementCount = 0
467
- if (orderId) {
468
- const { count, error: movementErr } = await pub
469
- .from('plg_financial_movements')
470
- .select('id', { count: 'exact', head: true })
471
- .eq('invoice_id', orderId)
472
- if (!movementErr) movementCount = count ?? 0
473
- }
474
-
475
- if (orderId && movementCount > 0) {
476
- // Cancel movements + order (don't delete financial records)
477
- await pub.from('plg_financial_movements').update({ status: 'cancelled' }).eq('invoice_id', orderId)
478
- await core.from('orders').update({ status: 'cancelled' }).eq('id', orderId)
479
- const { error } = await core.from('appointments').update({ status: 'cancelled' }).eq('id', id)
480
- if (error) throw error
481
- } else {
482
- // Safe to delete — remove any movements first (FK constraint)
483
- if (orderId) {
484
- await pub.from('plg_financial_movements').delete().eq('invoice_id', orderId)
485
- }
486
- const { error } = await core.from('appointments').delete().eq('id', id)
487
- if (error) throw error
488
- if (orderId) {
489
- const { error: orderErr } = await core.from('orders').delete().eq('id', orderId)
490
- if (orderErr) throw orderErr
491
- }
492
- }
493
- },
494
-
495
- async updateBookingStatus(id: string, status: string): Promise<CalendarBooking> {
496
- return this.updateBooking(id, { status: status as BookingStatus })
497
- },
498
-
499
- async checkConflict(params: ConflictCheckParams): Promise<boolean> {
500
- const { core } = getClients()
501
- let q = core.from('appointments').select('id', { count: 'exact', head: true })
502
- .eq('assignee_id', params.assigneeId)
503
- .eq('kind', 'appointment')
504
- .not('status', 'in', '("cancelled","no_show")')
505
- .not('starts_at', 'is', null)
506
- .lt('starts_at', params.endsAt)
507
- .gt('ends_at', params.startsAt)
508
-
509
- if (params.excludeBookingId) {
510
- q = q.neq('id', params.excludeBookingId)
511
- }
512
-
513
- const { count, error } = await q
514
- if (error) throw error
515
- return (count ?? 0) > 0
516
- },
517
-
518
- async getAvailableSlots(params: AvailableSlotsParams): Promise<TimeSlot[]> {
519
- const { pub } = getClients()
520
- const { data, error } = await pub.rpc('get_available_slots', {
521
- p_tenant_id: getTenantId(),
522
- p_assignee_id: params.assigneeId,
523
- p_date: params.date,
524
- p_duration_minutes: params.durationMinutes,
525
- p_slot_interval: params.slotInterval ?? 30,
526
- })
527
- if (error) throw error
528
- return (data ?? []).map((row: any) => ({ start: row.slot_start, end: row.slot_end }))
529
- },
530
-
531
- async getSchedules(professionalId?: string): Promise<Schedule[]> {
532
- const { core } = getClients()
533
- let q = core.from('schedules').select('*').eq('kind', 'working_hours')
534
- if (professionalId) q = q.eq('assignee_id', professionalId)
535
- q = q.order('day_of_week').order('starts_at')
536
-
537
- const { data, error } = await q
538
- if (error) throw error
539
- return (data ?? []).map(mapSchedule)
540
- },
541
-
542
- async saveSchedule(input: SaveScheduleInput): Promise<Schedule> {
543
- const { core } = getClients()
544
- const tenantId = getTenantId()
545
- if (!tenantId) throw new Error('No active tenant')
546
-
547
- const { data, error } = await core.from('schedules').insert({
548
- tenant_id: tenantId,
549
- kind: 'working_hours',
550
- assignee_id: input.assigneeId,
551
- location_id: input.locationId ?? null,
552
- day_of_week: input.dayOfWeek ?? null,
553
- specific_date: input.specificDate ?? null,
554
- starts_at: input.startsAt,
555
- ends_at: input.endsAt,
556
- is_active: input.isActive !== false,
557
- metadata: input.metadata ?? {},
558
- }).select('*').single()
559
- if (error) throw error
560
- return mapSchedule(data as Record<string, unknown>)
561
- },
562
-
563
- async deleteSchedule(id: string): Promise<void> {
564
- const { core } = getClients()
565
- const { error } = await core.from('schedules').delete().eq('id', id)
566
- if (error) throw error
567
- },
568
-
569
- async getProfessionals(): Promise<Professional[]> {
570
- const { core } = getClients()
571
- const { data, error } = await core.from('people')
572
- .select('id, name, avatar_url, is_active')
573
- .eq('kind', professionalKind)
574
- .eq('is_active', true)
575
- .order('name')
576
- if (error) throw error
577
- return (data ?? []).map((p: any) => ({
578
- id: p.id, name: p.name, avatarUrl: p.avatar_url,
579
- locationId: null, locationName: null, isActive: p.is_active,
580
- }))
581
- },
582
-
583
- async getLocations(): Promise<Array<{ id: string; name: string }>> {
584
- const { core } = getClients()
585
- const { data, error } = await core.from('locations')
586
- .select('id, name')
587
- .eq('is_active', true)
588
- .order('name')
589
- if (error) throw error
590
- return (data ?? []).map((l: any) => ({ id: l.id, name: l.name }))
591
- },
592
-
593
- async getConfirmationsPending(daysAhead = 2): Promise<CalendarBooking[]> {
594
- const { pub } = getClients()
595
- const now = new Date().toISOString()
596
- const cutoff = new Date()
597
- cutoff.setDate(cutoff.getDate() + daysAhead)
598
-
599
- const { data, error } = await pub.from('v_appointments').select('*')
600
- .eq('status', 'scheduled')
601
- .gte('starts_at', now)
602
- .lte('starts_at', cutoff.toISOString())
603
- .order('starts_at')
604
- if (error) throw error
605
- return (data ?? []).map(mapBooking)
606
- },
607
-
608
- async sendConfirmation(bookingId: string, channel: string): Promise<void> {
609
- const { core } = getClients()
610
- const { data: booking } = await core.from('appointments').select('metadata').eq('id', bookingId).single()
611
- const metadata = (booking?.metadata ?? {}) as Record<string, unknown>
612
- const confirmations = (metadata.confirmations as any[] ?? [])
613
- confirmations.push({ channel, sentAt: new Date().toISOString(), status: 'sent' })
614
- await core.from('appointments').update({ metadata: { ...metadata, confirmations } }).eq('id', bookingId)
615
- },
616
-
617
- async completeBooking(bookingId: string): Promise<{ booking: CalendarBooking; orderId: string }> {
618
- const { core } = getClients()
619
- const { data: existing, error: existingErr } = await core.from('appointments')
620
- .select('order_id')
621
- .eq('id', bookingId)
622
- .single()
623
- if (existingErr) throw existingErr
624
-
625
- const { error } = await core.from('appointments')
626
- .update({ status: 'completed' })
627
- .eq('id', bookingId)
628
- if (error) throw error
629
-
630
- const orderId = existing?.order_id as string | null | undefined
631
- const booking = await this.getBookingById(bookingId)
632
- if (!booking) throw new Error('Booking not found')
633
- return { booking, orderId: orderId ?? bookingId }
634
- },
635
- }
636
- }
package/src/data/types.ts DELETED
@@ -1,37 +0,0 @@
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
- }