@fayz-ai/plugin-agenda 0.8.1 → 0.8.4
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/dist/{chunk-DRZF6CV2.js → chunk-Q2PAWBPR.js} +62 -3
- package/dist/chunk-Q2PAWBPR.js.map +1 -0
- package/dist/{chunk-LFUELUIT.cjs → chunk-RX2HMWXS.cjs} +62 -2
- package/dist/chunk-RX2HMWXS.cjs.map +1 -0
- package/dist/components/AgendaGeneralSettings.d.ts.map +1 -1
- package/dist/components/AppointmentModal.d.ts.map +1 -1
- package/dist/components/CalendarToolbar.d.ts.map +1 -1
- package/dist/data/supabase.d.ts +5 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.cjs +1078 -768
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +921 -611
- package/dist/index.js.map +1 -1
- package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts +6 -0
- package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts +9 -0
- package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts +12 -0
- package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts +26 -0
- package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts.map +1 -0
- package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
- package/dist/integrations/google-calendar/data/supabase.d.ts +33 -1
- package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
- package/dist/integrations/google-calendar/data/tables.d.ts +6 -0
- package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -0
- package/dist/integrations/google-calendar/index.d.ts +1 -1
- package/dist/integrations/google-calendar/index.d.ts.map +1 -1
- package/dist/integrations/google-calendar/mapping.d.ts +56 -4
- package/dist/integrations/google-calendar/mapping.d.ts.map +1 -1
- package/dist/integrations/google-calendar/types.d.ts +101 -0
- package/dist/integrations/google-calendar/types.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +12 -1
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/public/BookingWidget.d.ts.map +1 -1
- package/dist/public/context.d.ts +3 -1
- package/dist/public/context.d.ts.map +1 -1
- package/dist/public/createPublicBookingPlugin.d.ts +7 -1
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
- package/dist/public/data.d.ts +6 -0
- package/dist/public/data.d.ts.map +1 -1
- package/dist/public/data.supabase.d.ts.map +1 -1
- package/dist/public/format.d.ts +22 -1
- package/dist/public/format.d.ts.map +1 -1
- package/dist/public/hooks.d.ts.map +1 -1
- package/dist/public/index.cjs +31 -63
- package/dist/public/index.cjs.map +1 -1
- package/dist/public/index.d.ts +1 -1
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +27 -61
- package/dist/public/index.js.map +1 -1
- package/dist/public/steps/ContactStep.d.ts +4 -2
- package/dist/public/steps/ContactStep.d.ts.map +1 -1
- package/dist/public/types.d.ts +16 -0
- package/dist/public/types.d.ts.map +1 -1
- package/dist/views/CalendarView.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/components/AgendaGeneralSettings.tsx +38 -16
- package/src/components/AppointmentModal.tsx +50 -108
- package/src/components/CalendarToolbar.tsx +10 -7
- package/src/data/supabase.ts +67 -13
- package/src/index.ts +56 -8
- package/src/integrations/google-calendar/components/CalendarColorDot.tsx +23 -0
- package/src/integrations/google-calendar/components/ChannelMappingList.tsx +329 -0
- package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +215 -0
- package/src/integrations/google-calendar/components/useAgendaLookups.ts +100 -0
- package/src/integrations/google-calendar/connectorDef.tsx +123 -40
- package/src/integrations/google-calendar/data/supabase.ts +109 -13
- package/src/integrations/google-calendar/data/tables.ts +12 -0
- package/src/integrations/google-calendar/index.ts +10 -1
- package/src/integrations/google-calendar/integration.test.ts +126 -1
- package/src/integrations/google-calendar/mapping.ts +149 -9
- package/src/integrations/google-calendar/migrations/001_google_calendar.sql +9 -6
- package/src/integrations/google-calendar/migrations/002_smart_sync.sql +517 -0
- package/src/integrations/google-calendar/migrations/003_plg_rename.sql +273 -0
- package/src/integrations/google-calendar/types.ts +111 -0
- package/src/locales/en.ts +1 -0
- package/src/locales/pt-BR.ts +1 -0
- package/src/migrations/001_public_booking.sql +7 -2
- package/src/migrations/003_public_client_kind.sql +175 -0
- package/src/migrations/004_public_booking_assignee.sql +176 -0
- package/src/migrations/005_agent_rpcs.sql +168 -0
- package/src/migrations/006_agent_rpc_localtime.sql +181 -0
- package/src/migrations/007_schedule_exception_precedence.sql +290 -0
- package/src/migrations/008_agent_rpc_exception_precedence.sql +172 -0
- package/src/migrations/009_public_booking_location.sql +162 -0
- package/src/migrations/010_public_service_curation.sql +31 -0
- package/src/migrations/011_default_assignee.sql +167 -0
- package/src/migrations/012_drop_legacy_booking_overload.sql +29 -0
- package/src/migrations/013_normalize_v_appointments.sql +199 -0
- package/src/migrations/index.ts +1791 -3
- package/src/public/BookingWidget.tsx +10 -1
- package/src/public/context.tsx +3 -0
- package/src/public/createPublicBookingPlugin.ts +14 -1
- package/src/public/data.supabase.ts +7 -4
- package/src/public/data.ts +6 -0
- package/src/public/format.ts +36 -1
- package/src/public/hooks.ts +7 -2
- package/src/public/index.ts +1 -0
- package/src/public/steps/ContactStep.tsx +10 -5
- package/src/public/types.ts +21 -0
- package/src/views/CalendarView.tsx +15 -1
- package/dist/chunk-3LJKZQK4.js +0 -12
- package/dist/chunk-3LJKZQK4.js.map +0 -1
- package/dist/chunk-DRZF6CV2.js.map +0 -1
- package/dist/chunk-LFUELUIT.cjs.map +0 -1
- package/dist/chunk-SYDBDI3Q.cjs +0 -15
- package/dist/chunk-SYDBDI3Q.cjs.map +0 -1
- package/dist/components/SearchCombobox.d.ts +0 -38
- package/dist/components/SearchCombobox.d.ts.map +0 -1
- package/dist/tenant-222EHYUC.cjs +0 -16
- package/dist/tenant-222EHYUC.cjs.map +0 -1
- package/dist/tenant-LAUWVUYB.js +0 -3
- package/dist/tenant-LAUWVUYB.js.map +0 -1
- package/src/components/SearchCombobox.tsx +0 -216
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
|
|
|
22
22
|
const config = resolveConfig(options)
|
|
23
23
|
registerTranslations(agendaLocales)
|
|
24
24
|
const provider = options?.dataProvider ?? createSafeDataProvider(
|
|
25
|
-
() => createSupabaseAgendaProvider(),
|
|
25
|
+
() => createSupabaseAgendaProvider({ professionalKind: config.professionalKind }),
|
|
26
26
|
() => createMockAgendaProvider(),
|
|
27
27
|
)
|
|
28
28
|
const store = createAgendaStore(provider, options?.financialBridge)
|
|
@@ -66,6 +66,49 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
|
|
|
66
66
|
{ id: 'appointments', label: config.labels.pageTitle, group: config.labels.pageTitle },
|
|
67
67
|
{ id: 'agenda.schedules', label: 'Schedule Management', group: config.labels.pageTitle },
|
|
68
68
|
],
|
|
69
|
+
// Recurring monthly quota — counts appointment rows created this month.
|
|
70
|
+
// (Clients/patients are the app's CRUD entity, so their cap is declared by
|
|
71
|
+
// the app's EntityDef.limitKey, not here.)
|
|
72
|
+
declaredLimits: [
|
|
73
|
+
{ key: 'bookings_month', label: 'Appointments this month', table: 'appointments', period: 'month' },
|
|
74
|
+
],
|
|
75
|
+
queryEntities: [
|
|
76
|
+
{
|
|
77
|
+
key: 'agenda:appointments',
|
|
78
|
+
entity: {
|
|
79
|
+
name: 'Appointment',
|
|
80
|
+
namePlural: 'Appointments',
|
|
81
|
+
icon: 'Calendar',
|
|
82
|
+
permission: { feature: 'appointments', action: 'read' },
|
|
83
|
+
// Keys are the PROVIDER's output shape (camelCase) — what filters/
|
|
84
|
+
// dateField/groupBy match against, not the raw DB columns.
|
|
85
|
+
fields: [
|
|
86
|
+
{ key: 'startsAt', label: 'Starts at', type: 'text' },
|
|
87
|
+
{ key: 'endsAt', label: 'Ends at', type: 'text' },
|
|
88
|
+
{ key: 'status', label: 'Status', type: 'text' },
|
|
89
|
+
{ key: 'clientName', label: 'Client', type: 'text', searchable: true },
|
|
90
|
+
{ key: 'professionalName', label: 'Professional', type: 'text', searchable: true },
|
|
91
|
+
{ key: 'orderTotal', label: 'Order total', type: 'number' },
|
|
92
|
+
{ key: 'totalDurationMinutes', label: 'Duration (min)', type: 'number' },
|
|
93
|
+
{ key: 'createdAt', label: 'Created at', type: 'text' },
|
|
94
|
+
],
|
|
95
|
+
data: {
|
|
96
|
+
table: 'v_appointments',
|
|
97
|
+
tenantScoped: true,
|
|
98
|
+
searchColumns: ['client_name', 'professional_name'],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
declaredRpcs: [
|
|
104
|
+
{
|
|
105
|
+
name: 'agent_agenda_create_appointment',
|
|
106
|
+
kind: 'write' as const,
|
|
107
|
+
description:
|
|
108
|
+
'Guarded appointment create: agent_guard (role→plan→bookings_month cap), working hours, race-safe conflict check, audited.',
|
|
109
|
+
audits: true,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
69
112
|
|
|
70
113
|
navigation: [
|
|
71
114
|
{
|
|
@@ -83,6 +126,8 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
|
|
|
83
126
|
path: '/agenda',
|
|
84
127
|
component: PageComponent,
|
|
85
128
|
permission: { feature: 'appointments', action: 'read' as const },
|
|
129
|
+
// Matches the ref agent_agenda_create_appointment returns.
|
|
130
|
+
entityRoutes: ['schedule:appointment'],
|
|
86
131
|
},
|
|
87
132
|
],
|
|
88
133
|
|
|
@@ -113,20 +158,23 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
|
|
|
113
158
|
{
|
|
114
159
|
id: 'agenda.create-appointment',
|
|
115
160
|
name: 'createAppointment',
|
|
116
|
-
description:
|
|
161
|
+
description:
|
|
162
|
+
'Creates a new appointment. Takes IDS, not names: resolve the client/service/professional ids first via findAnything/searchRecords, then call this with an ISO start time in the tenant timezone. The user confirms before booking.',
|
|
117
163
|
icon: 'CalendarPlus',
|
|
118
164
|
mode: 'persist' as const,
|
|
165
|
+
limitKey: 'bookings_month',
|
|
166
|
+
execution: { plane: 'server' as const, kind: 'rpc' as const, rpc: 'agent_agenda_create_appointment' },
|
|
119
167
|
category: 'Scheduling',
|
|
120
168
|
parameters: {
|
|
121
169
|
type: 'object' as const,
|
|
122
170
|
properties: {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
171
|
+
client_id: { type: 'string' as const, description: 'Client id (from a search)' },
|
|
172
|
+
service_id: { type: 'string' as const, description: 'Service id (from a search)' },
|
|
173
|
+
professional_id: { type: 'string' as const, description: 'Professional id (optional — auto-assigned when omitted)' },
|
|
174
|
+
starts_at: { type: 'string' as const, description: "LOCAL date-time of the business, NO timezone suffix — e.g. '2026-07-27T10:00' for 10h. The system applies the tenant timezone." },
|
|
175
|
+
notes: { type: 'string' as const, description: 'Optional notes' },
|
|
128
176
|
},
|
|
129
|
-
required: ['
|
|
177
|
+
required: ['client_id', 'service_id', 'starts_at'],
|
|
130
178
|
},
|
|
131
179
|
suggestions: [
|
|
132
180
|
{ label: 'Book a haircut for Sarah tomorrow at 10am' },
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// A small round swatch tinted with a Google calendar's backgroundColor.
|
|
2
|
+
//
|
|
3
|
+
// Google's calendarList entries carry a `backgroundColor` hex; we render it as a
|
|
4
|
+
// dot next to the calendar summary in the de-para list and as the leading dot of
|
|
5
|
+
// the origin badge in the import wizard. Falls back to the muted border when a
|
|
6
|
+
// calendar has no color (rare, e.g. some shared calendars).
|
|
7
|
+
import React from 'react'
|
|
8
|
+
|
|
9
|
+
export function CalendarColorDot({
|
|
10
|
+
color,
|
|
11
|
+
className = '',
|
|
12
|
+
}: {
|
|
13
|
+
color?: string | null
|
|
14
|
+
className?: string
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<span
|
|
18
|
+
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 ${className}`}
|
|
19
|
+
style={{ backgroundColor: color || 'hsl(var(--muted-foreground))' }}
|
|
20
|
+
aria-hidden
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// De-para (mapping) list — one row per Google calendar.
|
|
2
|
+
//
|
|
3
|
+
// Reads the user's Google calendars (listGoogleCalendars) and this tenant's
|
|
4
|
+
// existing channels (listChannels), joins them by google_calendar_id, and lets
|
|
5
|
+
// the user, per calendar:
|
|
6
|
+
// • toggle sync on/off → creates/updates the channel (is_active)
|
|
7
|
+
// • pick a direction → Two-way / Import only / Export only / Off
|
|
8
|
+
// • pick a target → Whole agenda / Professional / Service / Location
|
|
9
|
+
// • pick an import mode → Block / Appointment
|
|
10
|
+
// Every change is persisted immediately via saveChannel (RLS upsert on
|
|
11
|
+
// integration_id + google_calendar_id), snapshotting the calendar summary+color
|
|
12
|
+
// onto the channel. Target ids come from real host lookups (useAgendaLookups);
|
|
13
|
+
// if a lookup is empty for the tenant the row degrades to a raw uuid input.
|
|
14
|
+
import React, { useCallback, useEffect, useState } from 'react'
|
|
15
|
+
import { Loader2, AlertCircle, RefreshCw } from 'lucide-react'
|
|
16
|
+
import {
|
|
17
|
+
Button,
|
|
18
|
+
Checkbox,
|
|
19
|
+
Badge,
|
|
20
|
+
Select,
|
|
21
|
+
SelectTrigger,
|
|
22
|
+
SelectValue,
|
|
23
|
+
SelectContent,
|
|
24
|
+
SelectItem,
|
|
25
|
+
toast,
|
|
26
|
+
} from '@fayz-ai/ui'
|
|
27
|
+
import type { GoogleCalendarProvider } from '../data/supabase'
|
|
28
|
+
import type {
|
|
29
|
+
CalendarChannel,
|
|
30
|
+
ChannelDirection,
|
|
31
|
+
ChannelTargetKind,
|
|
32
|
+
ChannelImportMode,
|
|
33
|
+
GoogleCalendarListEntry,
|
|
34
|
+
} from '../types'
|
|
35
|
+
import { CalendarColorDot } from './CalendarColorDot'
|
|
36
|
+
import { useAgendaLookups, optionsForKind } from './useAgendaLookups'
|
|
37
|
+
|
|
38
|
+
const DIRECTION_LABELS: Record<ChannelDirection, string> = {
|
|
39
|
+
bidirectional: 'Two-way',
|
|
40
|
+
inbound: 'Import only',
|
|
41
|
+
outbound: 'Export only',
|
|
42
|
+
off: 'Off',
|
|
43
|
+
}
|
|
44
|
+
const IMPORT_MODE_LABELS: Record<ChannelImportMode, string> = {
|
|
45
|
+
block: 'Block',
|
|
46
|
+
appointment: 'Appointment',
|
|
47
|
+
}
|
|
48
|
+
// 'agenda' is the UI sentinel for the whole-agenda target (target_kind = null).
|
|
49
|
+
const TARGET_KIND_LABELS: Record<'agenda' | 'assignee' | 'service' | 'location', string> = {
|
|
50
|
+
agenda: 'Whole agenda',
|
|
51
|
+
assignee: 'Professional',
|
|
52
|
+
service: 'Service',
|
|
53
|
+
location: 'Location',
|
|
54
|
+
}
|
|
55
|
+
const NO_TARGET = '__none__'
|
|
56
|
+
|
|
57
|
+
/** Defaults for a freshly-enabled calendar (mirror the migration defaults). */
|
|
58
|
+
function defaultChannel(): {
|
|
59
|
+
direction: ChannelDirection
|
|
60
|
+
targetKind: ChannelTargetKind
|
|
61
|
+
targetId: string | null
|
|
62
|
+
importMode: ChannelImportMode
|
|
63
|
+
} {
|
|
64
|
+
return { direction: 'bidirectional', targetKind: null, targetId: null, importMode: 'block' }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ChannelMappingList({
|
|
68
|
+
provider,
|
|
69
|
+
onChannelsChange,
|
|
70
|
+
}: {
|
|
71
|
+
provider: GoogleCalendarProvider
|
|
72
|
+
/** Bubble the current channels up so the parent can gate the import wizard. */
|
|
73
|
+
onChannelsChange?: (channels: CalendarChannel[]) => void
|
|
74
|
+
}) {
|
|
75
|
+
const [calendars, setCalendars] = useState<GoogleCalendarListEntry[]>([])
|
|
76
|
+
const [byGcalId, setByGcalId] = useState<Record<string, CalendarChannel>>({})
|
|
77
|
+
const [loading, setLoading] = useState(true)
|
|
78
|
+
const [error, setError] = useState<string | null>(null)
|
|
79
|
+
const [savingId, setSavingId] = useState<string | null>(null)
|
|
80
|
+
|
|
81
|
+
const { lookups, hasAny: hasLookups } = useAgendaLookups(true)
|
|
82
|
+
|
|
83
|
+
const load = useCallback(async () => {
|
|
84
|
+
setLoading(true)
|
|
85
|
+
setError(null)
|
|
86
|
+
try {
|
|
87
|
+
const [cals, channels] = await Promise.all([
|
|
88
|
+
provider.listGoogleCalendars(),
|
|
89
|
+
provider.listChannels(),
|
|
90
|
+
])
|
|
91
|
+
setCalendars(cals)
|
|
92
|
+
const map: Record<string, CalendarChannel> = {}
|
|
93
|
+
for (const ch of channels) map[ch.googleCalendarId] = ch
|
|
94
|
+
setByGcalId(map)
|
|
95
|
+
onChannelsChange?.(channels)
|
|
96
|
+
} catch (e: any) {
|
|
97
|
+
setError(e?.message ?? 'Could not load Google calendars')
|
|
98
|
+
} finally {
|
|
99
|
+
setLoading(false)
|
|
100
|
+
}
|
|
101
|
+
}, [provider, onChannelsChange])
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
void load()
|
|
105
|
+
}, [load])
|
|
106
|
+
|
|
107
|
+
/** Persist a patch for one calendar, then refresh channels (keeps ids correct). */
|
|
108
|
+
const persist = useCallback(
|
|
109
|
+
async (
|
|
110
|
+
cal: GoogleCalendarListEntry,
|
|
111
|
+
patch: {
|
|
112
|
+
direction?: ChannelDirection
|
|
113
|
+
targetKind?: ChannelTargetKind
|
|
114
|
+
targetId?: string | null
|
|
115
|
+
importMode?: ChannelImportMode
|
|
116
|
+
isActive?: boolean
|
|
117
|
+
},
|
|
118
|
+
) => {
|
|
119
|
+
const existing = byGcalId[cal.id]
|
|
120
|
+
const base = existing ?? { ...defaultChannel(), isActive: true }
|
|
121
|
+
setSavingId(cal.id)
|
|
122
|
+
try {
|
|
123
|
+
await provider.saveChannel({
|
|
124
|
+
id: existing?.id,
|
|
125
|
+
googleCalendarId: cal.id,
|
|
126
|
+
summary: cal.summary,
|
|
127
|
+
color: cal.backgroundColor ?? null,
|
|
128
|
+
direction: patch.direction ?? base.direction,
|
|
129
|
+
targetKind: patch.targetKind !== undefined ? patch.targetKind : base.targetKind,
|
|
130
|
+
targetId: patch.targetId !== undefined ? patch.targetId : base.targetId ?? null,
|
|
131
|
+
importMode: patch.importMode ?? base.importMode,
|
|
132
|
+
isActive: patch.isActive !== undefined ? patch.isActive : base.isActive,
|
|
133
|
+
})
|
|
134
|
+
const channels = await provider.listChannels()
|
|
135
|
+
const map: Record<string, CalendarChannel> = {}
|
|
136
|
+
for (const ch of channels) map[ch.googleCalendarId] = ch
|
|
137
|
+
setByGcalId(map)
|
|
138
|
+
onChannelsChange?.(channels)
|
|
139
|
+
} catch (e: any) {
|
|
140
|
+
toast.error(e?.message ?? 'Could not save calendar mapping')
|
|
141
|
+
} finally {
|
|
142
|
+
setSavingId(null)
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
[byGcalId, provider, onChannelsChange],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if (loading) {
|
|
149
|
+
return (
|
|
150
|
+
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
151
|
+
<Loader2 className="h-4 w-4 animate-spin inline" />
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (error) {
|
|
157
|
+
return (
|
|
158
|
+
<div className="rounded-md border border-warning/40 bg-warning-soft/40 px-3 py-3 text-sm">
|
|
159
|
+
<div className="flex items-center gap-2 text-warning-soft-foreground">
|
|
160
|
+
<AlertCircle className="h-4 w-4" /> {error}
|
|
161
|
+
</div>
|
|
162
|
+
<Button variant="outline" size="sm" className="mt-2" onClick={() => void load()}>
|
|
163
|
+
<RefreshCw className="h-3.5 w-3.5" /> Retry
|
|
164
|
+
</Button>
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (calendars.length === 0) {
|
|
170
|
+
return (
|
|
171
|
+
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
172
|
+
No Google calendars found on this account.
|
|
173
|
+
</p>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="rounded-md border divide-y">
|
|
179
|
+
{calendars.map((cal) => {
|
|
180
|
+
const ch = byGcalId[cal.id]
|
|
181
|
+
const active = !!ch?.isActive
|
|
182
|
+
const saving = savingId === cal.id
|
|
183
|
+
const direction: ChannelDirection = ch?.direction ?? 'bidirectional'
|
|
184
|
+
const targetKind: ChannelTargetKind = ch?.targetKind ?? null
|
|
185
|
+
const importMode: ChannelImportMode = ch?.importMode ?? 'block'
|
|
186
|
+
// Import mode only matters when Google can write into the agenda.
|
|
187
|
+
const canImport = direction === 'inbound' || direction === 'bidirectional'
|
|
188
|
+
const kindOptions = optionsForKind(lookups, targetKind)
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div key={cal.id} className="px-3 py-3 space-y-3">
|
|
192
|
+
<div className="flex items-center gap-2.5">
|
|
193
|
+
<Checkbox
|
|
194
|
+
checked={active}
|
|
195
|
+
disabled={saving}
|
|
196
|
+
onChange={(next) => void persist(cal, { isActive: next })}
|
|
197
|
+
/>
|
|
198
|
+
<CalendarColorDot color={cal.backgroundColor} />
|
|
199
|
+
<span className="text-sm font-medium truncate">{cal.summary}</span>
|
|
200
|
+
{cal.primary && (
|
|
201
|
+
<Badge variant="outline" className="shrink-0">
|
|
202
|
+
Primary
|
|
203
|
+
</Badge>
|
|
204
|
+
)}
|
|
205
|
+
{saving && <Loader2 className="ml-auto h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{active && (
|
|
209
|
+
<div className="grid gap-2 pl-[26px] sm:grid-cols-2 lg:grid-cols-4">
|
|
210
|
+
{/* Direction */}
|
|
211
|
+
<label className="block">
|
|
212
|
+
<span className="text-[11px] font-medium text-muted-foreground">Direction</span>
|
|
213
|
+
<Select
|
|
214
|
+
value={direction}
|
|
215
|
+
onValueChange={(v) => void persist(cal, { direction: v as ChannelDirection })}
|
|
216
|
+
>
|
|
217
|
+
<SelectTrigger className="mt-1 h-8 text-sm">
|
|
218
|
+
<SelectValue />
|
|
219
|
+
</SelectTrigger>
|
|
220
|
+
<SelectContent>
|
|
221
|
+
{(Object.keys(DIRECTION_LABELS) as ChannelDirection[]).map((d) => (
|
|
222
|
+
<SelectItem key={d} value={d}>
|
|
223
|
+
{DIRECTION_LABELS[d]}
|
|
224
|
+
</SelectItem>
|
|
225
|
+
))}
|
|
226
|
+
</SelectContent>
|
|
227
|
+
</Select>
|
|
228
|
+
</label>
|
|
229
|
+
|
|
230
|
+
{/* Target kind */}
|
|
231
|
+
<label className="block">
|
|
232
|
+
<span className="text-[11px] font-medium text-muted-foreground">Target</span>
|
|
233
|
+
<Select
|
|
234
|
+
value={targetKind ?? 'agenda'}
|
|
235
|
+
onValueChange={(v) =>
|
|
236
|
+
void persist(cal, {
|
|
237
|
+
targetKind: v === 'agenda' ? null : (v as ChannelTargetKind),
|
|
238
|
+
// Reset the id whenever the kind changes.
|
|
239
|
+
targetId: null,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
>
|
|
243
|
+
<SelectTrigger className="mt-1 h-8 text-sm">
|
|
244
|
+
<SelectValue />
|
|
245
|
+
</SelectTrigger>
|
|
246
|
+
<SelectContent>
|
|
247
|
+
{(Object.keys(TARGET_KIND_LABELS) as Array<keyof typeof TARGET_KIND_LABELS>).map(
|
|
248
|
+
(k) => (
|
|
249
|
+
<SelectItem key={k} value={k}>
|
|
250
|
+
{TARGET_KIND_LABELS[k]}
|
|
251
|
+
</SelectItem>
|
|
252
|
+
),
|
|
253
|
+
)}
|
|
254
|
+
</SelectContent>
|
|
255
|
+
</Select>
|
|
256
|
+
</label>
|
|
257
|
+
|
|
258
|
+
{/* Target id — real picker when we have lookups, uuid input otherwise */}
|
|
259
|
+
{targetKind && (
|
|
260
|
+
<label className="block">
|
|
261
|
+
<span className="text-[11px] font-medium text-muted-foreground">
|
|
262
|
+
{TARGET_KIND_LABELS[targetKind]}
|
|
263
|
+
</span>
|
|
264
|
+
{kindOptions.length > 0 ? (
|
|
265
|
+
<Select
|
|
266
|
+
value={ch?.targetId ?? NO_TARGET}
|
|
267
|
+
onValueChange={(v) =>
|
|
268
|
+
void persist(cal, { targetId: v === NO_TARGET ? null : v })
|
|
269
|
+
}
|
|
270
|
+
>
|
|
271
|
+
<SelectTrigger className="mt-1 h-8 text-sm">
|
|
272
|
+
<SelectValue placeholder="Select…" />
|
|
273
|
+
</SelectTrigger>
|
|
274
|
+
<SelectContent>
|
|
275
|
+
{kindOptions.map((o) => (
|
|
276
|
+
<SelectItem key={o.id} value={o.id}>
|
|
277
|
+
{o.name}
|
|
278
|
+
</SelectItem>
|
|
279
|
+
))}
|
|
280
|
+
</SelectContent>
|
|
281
|
+
</Select>
|
|
282
|
+
) : (
|
|
283
|
+
<input
|
|
284
|
+
defaultValue={ch?.targetId ?? ''}
|
|
285
|
+
placeholder="uuid"
|
|
286
|
+
onBlur={(e) =>
|
|
287
|
+
void persist(cal, { targetId: e.target.value.trim() || null })
|
|
288
|
+
}
|
|
289
|
+
className="w-full mt-1 h-8 rounded-input border border-input bg-card px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
</label>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{/* Import mode — only when inbound */}
|
|
296
|
+
{canImport && (
|
|
297
|
+
<label className="block">
|
|
298
|
+
<span className="text-[11px] font-medium text-muted-foreground">Import as</span>
|
|
299
|
+
<Select
|
|
300
|
+
value={importMode}
|
|
301
|
+
onValueChange={(v) => void persist(cal, { importMode: v as ChannelImportMode })}
|
|
302
|
+
>
|
|
303
|
+
<SelectTrigger className="mt-1 h-8 text-sm">
|
|
304
|
+
<SelectValue />
|
|
305
|
+
</SelectTrigger>
|
|
306
|
+
<SelectContent>
|
|
307
|
+
{(Object.keys(IMPORT_MODE_LABELS) as ChannelImportMode[]).map((m) => (
|
|
308
|
+
<SelectItem key={m} value={m}>
|
|
309
|
+
{IMPORT_MODE_LABELS[m]}
|
|
310
|
+
</SelectItem>
|
|
311
|
+
))}
|
|
312
|
+
</SelectContent>
|
|
313
|
+
</Select>
|
|
314
|
+
</label>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
})}
|
|
321
|
+
{!hasLookups && (
|
|
322
|
+
<p className="px-3 py-2 text-[11px] text-muted-foreground">
|
|
323
|
+
No professional/service/location lookups available — target ids fall back to manual uuid
|
|
324
|
+
entry.
|
|
325
|
+
</p>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Import wizard — pull FUTURE Google events into the agenda as blocks/appointments.
|
|
2
|
+
//
|
|
3
|
+
// listExternalEvents() returns events on the active inbound channels that are not
|
|
4
|
+
// yet linked to an appointment. The user ticks the ones to bring in and confirms;
|
|
5
|
+
// importEvents() runs them through gcal_import_event (honouring each channel's
|
|
6
|
+
// import_mode + target) and returns a per-item tally {imported, skipped, errors}.
|
|
7
|
+
// Events show their source calendar as a colored badge and an "All-day" badge
|
|
8
|
+
// when applicable; times are formatted pt-BR.
|
|
9
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
10
|
+
import { Loader2, CalendarPlus, CheckCircle2, AlertCircle, RefreshCw, X } from 'lucide-react'
|
|
11
|
+
import { Button, Checkbox, Badge, toast } from '@fayz-ai/ui'
|
|
12
|
+
import type { GoogleCalendarProvider } from '../data/supabase'
|
|
13
|
+
import type { CalendarChannel, ExternalEventPreview, ImportEventsResult } from '../types'
|
|
14
|
+
import { CalendarColorDot } from './CalendarColorDot'
|
|
15
|
+
|
|
16
|
+
/** pt-BR datetime label, all-day aware (date only for all-day events). */
|
|
17
|
+
function formatWhen(ev: ExternalEventPreview): string {
|
|
18
|
+
const start = new Date(ev.startsAt)
|
|
19
|
+
if (ev.allDay) {
|
|
20
|
+
return start.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' })
|
|
21
|
+
}
|
|
22
|
+
return start.toLocaleString('pt-BR', {
|
|
23
|
+
day: '2-digit',
|
|
24
|
+
month: 'short',
|
|
25
|
+
hour: '2-digit',
|
|
26
|
+
minute: '2-digit',
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ImportEventsWizard({
|
|
31
|
+
provider,
|
|
32
|
+
channels,
|
|
33
|
+
onClose,
|
|
34
|
+
onImported,
|
|
35
|
+
}: {
|
|
36
|
+
provider: GoogleCalendarProvider
|
|
37
|
+
/** Active channels — used to label each event with its source calendar + color. */
|
|
38
|
+
channels: CalendarChannel[]
|
|
39
|
+
onClose: () => void
|
|
40
|
+
/** Fired after a successful import so the parent can refresh status/history. */
|
|
41
|
+
onImported?: (result: ImportEventsResult) => void
|
|
42
|
+
}) {
|
|
43
|
+
const [events, setEvents] = useState<ExternalEventPreview[]>([])
|
|
44
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
45
|
+
const [loading, setLoading] = useState(true)
|
|
46
|
+
const [error, setError] = useState<string | null>(null)
|
|
47
|
+
const [importing, setImporting] = useState(false)
|
|
48
|
+
const [result, setResult] = useState<ImportEventsResult | null>(null)
|
|
49
|
+
|
|
50
|
+
const channelById = useMemo(() => {
|
|
51
|
+
const m: Record<string, CalendarChannel> = {}
|
|
52
|
+
for (const c of channels) m[c.id] = c
|
|
53
|
+
return m
|
|
54
|
+
}, [channels])
|
|
55
|
+
|
|
56
|
+
const load = useCallback(async () => {
|
|
57
|
+
setLoading(true)
|
|
58
|
+
setError(null)
|
|
59
|
+
setResult(null)
|
|
60
|
+
try {
|
|
61
|
+
const rows = await provider.listExternalEvents()
|
|
62
|
+
setEvents(rows)
|
|
63
|
+
setSelected(new Set(rows.map((r) => r.eventId)))
|
|
64
|
+
} catch (e: any) {
|
|
65
|
+
setError(e?.message ?? 'Could not load upcoming events')
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false)
|
|
68
|
+
}
|
|
69
|
+
}, [provider])
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
void load()
|
|
73
|
+
}, [load])
|
|
74
|
+
|
|
75
|
+
const toggle = (eventId: string) =>
|
|
76
|
+
setSelected((prev) => {
|
|
77
|
+
const next = new Set(prev)
|
|
78
|
+
if (next.has(eventId)) next.delete(eventId)
|
|
79
|
+
else next.add(eventId)
|
|
80
|
+
return next
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const allSelected = events.length > 0 && selected.size === events.length
|
|
84
|
+
const toggleAll = () =>
|
|
85
|
+
setSelected(allSelected ? new Set() : new Set(events.map((e) => e.eventId)))
|
|
86
|
+
|
|
87
|
+
async function handleImport() {
|
|
88
|
+
const items = events
|
|
89
|
+
.filter((e) => selected.has(e.eventId))
|
|
90
|
+
.map((e) => ({ channelId: e.channelId, eventId: e.eventId }))
|
|
91
|
+
if (items.length === 0) return
|
|
92
|
+
setImporting(true)
|
|
93
|
+
try {
|
|
94
|
+
const res = await provider.importEvents(items)
|
|
95
|
+
setResult(res)
|
|
96
|
+
onImported?.(res)
|
|
97
|
+
toast.success(`Imported ${res.imported} · skipped ${res.skipped}`)
|
|
98
|
+
// Drop imported events from the list so the wizard reflects the new state.
|
|
99
|
+
await load()
|
|
100
|
+
} catch (e: any) {
|
|
101
|
+
toast.error(e?.message ?? 'Import failed')
|
|
102
|
+
} finally {
|
|
103
|
+
setImporting(false)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="rounded-md border bg-card">
|
|
109
|
+
<div className="flex items-center gap-2 border-b px-3 py-2">
|
|
110
|
+
<CalendarPlus className="h-4 w-4 text-muted-foreground" />
|
|
111
|
+
<h4 className="text-sm font-semibold">Import future events</h4>
|
|
112
|
+
<button
|
|
113
|
+
onClick={onClose}
|
|
114
|
+
className="ml-auto rounded p-1 text-muted-foreground hover:bg-muted/50"
|
|
115
|
+
aria-label="Close"
|
|
116
|
+
>
|
|
117
|
+
<X className="h-3.5 w-3.5" />
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{loading ? (
|
|
122
|
+
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
123
|
+
<Loader2 className="h-4 w-4 animate-spin inline" />
|
|
124
|
+
</div>
|
|
125
|
+
) : error ? (
|
|
126
|
+
<div className="p-3">
|
|
127
|
+
<div className="flex items-center gap-2 text-sm text-warning-soft-foreground">
|
|
128
|
+
<AlertCircle className="h-4 w-4" /> {error}
|
|
129
|
+
</div>
|
|
130
|
+
<Button variant="outline" size="sm" className="mt-2" onClick={() => void load()}>
|
|
131
|
+
<RefreshCw className="h-3.5 w-3.5" /> Retry
|
|
132
|
+
</Button>
|
|
133
|
+
</div>
|
|
134
|
+
) : events.length === 0 ? (
|
|
135
|
+
<div className="py-8 px-4 text-center space-y-1">
|
|
136
|
+
<p className="text-sm font-medium">You're all caught up</p>
|
|
137
|
+
<p className="text-xs text-muted-foreground">
|
|
138
|
+
No upcoming Google events left to import on your active calendars.
|
|
139
|
+
</p>
|
|
140
|
+
{result && (
|
|
141
|
+
<p className="text-xs text-success">
|
|
142
|
+
Imported {result.imported} · skipped {result.skipped}
|
|
143
|
+
{result.errors.length > 0 ? ` · ${result.errors.length} errors` : ''}
|
|
144
|
+
</p>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
) : (
|
|
148
|
+
<>
|
|
149
|
+
<div className="flex items-center gap-2.5 border-b px-3 py-2">
|
|
150
|
+
<Checkbox checked={allSelected} onChange={toggleAll} />
|
|
151
|
+
<span className="text-xs text-muted-foreground">
|
|
152
|
+
{selected.size} of {events.length} selected
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="max-h-[320px] overflow-y-auto divide-y">
|
|
156
|
+
{events.map((ev) => {
|
|
157
|
+
const ch = channelById[ev.channelId]
|
|
158
|
+
return (
|
|
159
|
+
<label
|
|
160
|
+
key={ev.eventId}
|
|
161
|
+
className="flex items-start gap-2.5 px-3 py-2.5 cursor-pointer hover:bg-muted/30"
|
|
162
|
+
>
|
|
163
|
+
<Checkbox
|
|
164
|
+
checked={selected.has(ev.eventId)}
|
|
165
|
+
onChange={() => toggle(ev.eventId)}
|
|
166
|
+
/>
|
|
167
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
168
|
+
<div className="flex items-center gap-2">
|
|
169
|
+
<span className="text-sm font-medium truncate">
|
|
170
|
+
{ev.summary || '(no title)'}
|
|
171
|
+
</span>
|
|
172
|
+
{ev.allDay && (
|
|
173
|
+
<Badge variant="secondary" className="shrink-0">
|
|
174
|
+
All-day
|
|
175
|
+
</Badge>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
179
|
+
<span>{formatWhen(ev)}</span>
|
|
180
|
+
<span className="inline-flex items-center gap-1">
|
|
181
|
+
<CalendarColorDot color={ch?.color} />
|
|
182
|
+
{ch?.summary ?? ev.calendarId}
|
|
183
|
+
</span>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</label>
|
|
187
|
+
)
|
|
188
|
+
})}
|
|
189
|
+
</div>
|
|
190
|
+
<div className="flex items-center gap-2 border-t px-3 py-2">
|
|
191
|
+
{result && (
|
|
192
|
+
<span className="text-xs text-muted-foreground">
|
|
193
|
+
Last run: {result.imported} imported
|
|
194
|
+
{result.errors.length > 0 ? `, ${result.errors.length} errors` : ''}
|
|
195
|
+
</span>
|
|
196
|
+
)}
|
|
197
|
+
<Button
|
|
198
|
+
size="sm"
|
|
199
|
+
className="ml-auto"
|
|
200
|
+
disabled={importing || selected.size === 0}
|
|
201
|
+
onClick={handleImport}
|
|
202
|
+
>
|
|
203
|
+
{importing ? (
|
|
204
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
205
|
+
) : (
|
|
206
|
+
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
207
|
+
)}
|
|
208
|
+
Import {selected.size > 0 ? selected.size : ''}
|
|
209
|
+
</Button>
|
|
210
|
+
</div>
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
}
|