@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.
- package/README.md +51 -0
- package/dist/AgendaContext.d.ts +13 -0
- package/dist/AgendaContext.d.ts.map +1 -0
- package/dist/AgendaPage.d.ts +11 -0
- package/dist/AgendaPage.d.ts.map +1 -0
- package/dist/bridges/create-financial-bridge.d.ts +19 -0
- package/dist/bridges/create-financial-bridge.d.ts.map +1 -0
- package/dist/calendar-styles.d.ts +8 -0
- package/dist/calendar-styles.d.ts.map +1 -0
- package/dist/chunk-3LJKZQK4.js +12 -0
- package/dist/chunk-3LJKZQK4.js.map +1 -0
- package/dist/chunk-SYDBDI3Q.cjs +15 -0
- package/dist/chunk-SYDBDI3Q.cjs.map +1 -0
- package/dist/components/AgendaGeneralSettings.d.ts +3 -0
- package/dist/components/AgendaGeneralSettings.d.ts.map +1 -0
- package/dist/components/AppointmentModal.d.ts +16 -0
- package/dist/components/AppointmentModal.d.ts.map +1 -0
- package/dist/components/AppointmentPopover.d.ts +14 -0
- package/dist/components/AppointmentPopover.d.ts.map +1 -0
- package/dist/components/BookingPaymentPanel.d.ts +18 -0
- package/dist/components/BookingPaymentPanel.d.ts.map +1 -0
- package/dist/components/CalendarSidebar.d.ts +3 -0
- package/dist/components/CalendarSidebar.d.ts.map +1 -0
- package/dist/components/CalendarToolbar.d.ts +13 -0
- package/dist/components/CalendarToolbar.d.ts.map +1 -0
- package/dist/components/EventContextMenu.d.ts +14 -0
- package/dist/components/EventContextMenu.d.ts.map +1 -0
- package/dist/components/FloatingPanel.d.ts +27 -0
- package/dist/components/FloatingPanel.d.ts.map +1 -0
- package/dist/components/MiniCalendar.d.ts +9 -0
- package/dist/components/MiniCalendar.d.ts.map +1 -0
- package/dist/components/PaymentStatusBadge.d.ts +13 -0
- package/dist/components/PaymentStatusBadge.d.ts.map +1 -0
- package/dist/components/SearchCombobox.d.ts +38 -0
- package/dist/components/SearchCombobox.d.ts.map +1 -0
- package/dist/components/StatusBadge.d.ts +5 -0
- package/dist/components/StatusBadge.d.ts.map +1 -0
- package/dist/config.d.ts +151 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/data/index.d.ts +4 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/mock.d.ts +3 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +3 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +26 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/financial-bridge.d.ts +92 -0
- package/dist/financial-bridge.d.ts.map +1 -0
- package/dist/hooks/useAgendaSettings.d.ts +7 -0
- package/dist/hooks/useAgendaSettings.d.ts.map +1 -0
- package/dist/index.cjs +5135 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5120 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/tenant.d.ts +3 -0
- package/dist/lib/tenant.d.ts.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/registries.d.ts +3 -0
- package/dist/registries.d.ts.map +1 -0
- package/dist/store.d.ts +66 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/tenant-222EHYUC.cjs +16 -0
- package/dist/tenant-222EHYUC.cjs.map +1 -0
- package/dist/tenant-LAUWVUYB.js +3 -0
- package/dist/tenant-LAUWVUYB.js.map +1 -0
- package/dist/types.d.ts +202 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/CalendarView.d.ts +3 -0
- package/dist/views/CalendarView.d.ts.map +1 -0
- package/dist/views/ConfirmationsView.d.ts +3 -0
- package/dist/views/ConfirmationsView.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/AgendaContext.tsx +11 -0
- package/src/AgendaPage.tsx +33 -0
- package/src/bridges/create-financial-bridge.ts +298 -0
- package/src/calendar-styles.css +245 -0
- package/src/calendar-styles.ts +285 -0
- package/src/components/AgendaGeneralSettings.tsx +64 -0
- package/src/components/AppointmentModal.tsx +1031 -0
- package/src/components/AppointmentPopover.tsx +221 -0
- package/src/components/BookingPaymentPanel.tsx +394 -0
- package/src/components/CalendarSidebar.tsx +119 -0
- package/src/components/CalendarToolbar.tsx +83 -0
- package/src/components/EventContextMenu.tsx +122 -0
- package/src/components/FloatingPanel.tsx +123 -0
- package/src/components/MiniCalendar.tsx +120 -0
- package/src/components/PaymentStatusBadge.tsx +62 -0
- package/src/components/SearchCombobox.tsx +216 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/config.ts +269 -0
- package/src/data/index.ts +3 -0
- package/src/data/mock.ts +324 -0
- package/src/data/supabase.ts +570 -0
- package/src/data/types.ts +37 -0
- package/src/financial-bridge.ts +96 -0
- package/src/hooks/useAgendaSettings.ts +17 -0
- package/src/index.ts +180 -0
- package/src/lib/tenant.ts +3 -0
- package/src/locales/en.ts +207 -0
- package/src/locales/index.ts +7 -0
- package/src/locales/pt-BR.ts +207 -0
- package/src/registries.ts +36 -0
- package/src/store.ts +304 -0
- package/src/types.ts +265 -0
- package/src/views/CalendarView.tsx +904 -0
- 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
|
+
}
|