@fayz-ai/plugin-agenda 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/{chunk-LFUELUIT.cjs → chunk-3KWNHV5A.cjs} +60 -2
  2. package/dist/chunk-3KWNHV5A.cjs.map +1 -0
  3. package/dist/{chunk-DRZF6CV2.js → chunk-YQO6B4G3.js} +60 -3
  4. package/dist/chunk-YQO6B4G3.js.map +1 -0
  5. package/dist/components/AgendaGeneralSettings.d.ts.map +1 -1
  6. package/dist/components/AppointmentModal.d.ts.map +1 -1
  7. package/dist/components/CalendarToolbar.d.ts.map +1 -1
  8. package/dist/data/supabase.d.ts +5 -1
  9. package/dist/data/supabase.d.ts.map +1 -1
  10. package/dist/index.cjs +1071 -766
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +914 -609
  14. package/dist/index.js.map +1 -1
  15. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts +6 -0
  16. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts.map +1 -0
  17. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts +9 -0
  18. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts.map +1 -0
  19. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts +12 -0
  20. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts.map +1 -0
  21. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts +26 -0
  22. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts.map +1 -0
  23. package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
  24. package/dist/integrations/google-calendar/data/supabase.d.ts +33 -1
  25. package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
  26. package/dist/integrations/google-calendar/data/tables.d.ts +6 -0
  27. package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -0
  28. package/dist/integrations/google-calendar/index.d.ts +1 -1
  29. package/dist/integrations/google-calendar/index.d.ts.map +1 -1
  30. package/dist/integrations/google-calendar/mapping.d.ts +56 -4
  31. package/dist/integrations/google-calendar/mapping.d.ts.map +1 -1
  32. package/dist/integrations/google-calendar/types.d.ts +101 -0
  33. package/dist/integrations/google-calendar/types.d.ts.map +1 -1
  34. package/dist/locales/en.d.ts.map +1 -1
  35. package/dist/locales/pt-BR.d.ts.map +1 -1
  36. package/dist/migrations/index.d.ts +11 -0
  37. package/dist/migrations/index.d.ts.map +1 -1
  38. package/dist/public/BookingWidget.d.ts.map +1 -1
  39. package/dist/public/context.d.ts +3 -1
  40. package/dist/public/context.d.ts.map +1 -1
  41. package/dist/public/createPublicBookingPlugin.d.ts +7 -1
  42. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
  43. package/dist/public/data.d.ts +6 -0
  44. package/dist/public/data.d.ts.map +1 -1
  45. package/dist/public/data.supabase.d.ts.map +1 -1
  46. package/dist/public/format.d.ts +22 -1
  47. package/dist/public/format.d.ts.map +1 -1
  48. package/dist/public/hooks.d.ts.map +1 -1
  49. package/dist/public/index.cjs +31 -63
  50. package/dist/public/index.cjs.map +1 -1
  51. package/dist/public/index.d.ts +1 -1
  52. package/dist/public/index.d.ts.map +1 -1
  53. package/dist/public/index.js +27 -61
  54. package/dist/public/index.js.map +1 -1
  55. package/dist/public/steps/ContactStep.d.ts +4 -2
  56. package/dist/public/steps/ContactStep.d.ts.map +1 -1
  57. package/dist/public/types.d.ts +16 -0
  58. package/dist/public/types.d.ts.map +1 -1
  59. package/dist/views/CalendarView.d.ts.map +1 -1
  60. package/package.json +5 -5
  61. package/src/components/AgendaGeneralSettings.tsx +38 -16
  62. package/src/components/AppointmentModal.tsx +50 -108
  63. package/src/components/CalendarToolbar.tsx +10 -7
  64. package/src/data/supabase.ts +67 -13
  65. package/src/index.ts +56 -8
  66. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +23 -0
  67. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +329 -0
  68. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +215 -0
  69. package/src/integrations/google-calendar/components/useAgendaLookups.ts +100 -0
  70. package/src/integrations/google-calendar/connectorDef.tsx +123 -40
  71. package/src/integrations/google-calendar/data/supabase.ts +109 -13
  72. package/src/integrations/google-calendar/data/tables.ts +12 -0
  73. package/src/integrations/google-calendar/index.ts +10 -1
  74. package/src/integrations/google-calendar/integration.test.ts +126 -1
  75. package/src/integrations/google-calendar/mapping.ts +149 -9
  76. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +9 -6
  77. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +517 -0
  78. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +273 -0
  79. package/src/integrations/google-calendar/types.ts +111 -0
  80. package/src/locales/en.ts +1 -0
  81. package/src/locales/pt-BR.ts +1 -0
  82. package/src/migrations/003_public_client_kind.sql +175 -0
  83. package/src/migrations/004_public_booking_assignee.sql +176 -0
  84. package/src/migrations/005_agent_rpcs.sql +168 -0
  85. package/src/migrations/006_agent_rpc_localtime.sql +181 -0
  86. package/src/migrations/007_schedule_exception_precedence.sql +290 -0
  87. package/src/migrations/008_agent_rpc_exception_precedence.sql +172 -0
  88. package/src/migrations/009_public_booking_location.sql +162 -0
  89. package/src/migrations/010_public_service_curation.sql +31 -0
  90. package/src/migrations/011_default_assignee.sql +167 -0
  91. package/src/migrations/012_drop_legacy_booking_overload.sql +29 -0
  92. package/src/migrations/013_normalize_v_appointments.sql +161 -0
  93. package/src/migrations/index.ts +1746 -1
  94. package/src/public/BookingWidget.tsx +10 -1
  95. package/src/public/context.tsx +3 -0
  96. package/src/public/createPublicBookingPlugin.ts +14 -1
  97. package/src/public/data.supabase.ts +3 -0
  98. package/src/public/data.ts +6 -0
  99. package/src/public/format.ts +36 -1
  100. package/src/public/hooks.ts +7 -2
  101. package/src/public/index.ts +1 -0
  102. package/src/public/steps/ContactStep.tsx +10 -5
  103. package/src/public/types.ts +21 -0
  104. package/src/views/CalendarView.tsx +15 -1
  105. package/dist/chunk-3LJKZQK4.js +0 -12
  106. package/dist/chunk-3LJKZQK4.js.map +0 -1
  107. package/dist/chunk-DRZF6CV2.js.map +0 -1
  108. package/dist/chunk-LFUELUIT.cjs.map +0 -1
  109. package/dist/chunk-SYDBDI3Q.cjs +0 -15
  110. package/dist/chunk-SYDBDI3Q.cjs.map +0 -1
  111. package/dist/components/SearchCombobox.d.ts +0 -38
  112. package/dist/components/SearchCombobox.d.ts.map +0 -1
  113. package/dist/tenant-222EHYUC.cjs +0 -16
  114. package/dist/tenant-222EHYUC.cjs.map +0 -1
  115. package/dist/tenant-LAUWVUYB.js +0 -3
  116. package/dist/tenant-LAUWVUYB.js.map +0 -1
  117. 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: 'Creates a new appointment for a client with a specific professional and service.',
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
- client: { type: 'string' as const, description: 'Client name' },
124
- professional: { type: 'string' as const, description: 'Professional name' },
125
- service: { type: 'string' as const, description: 'Service name' },
126
- date: { type: 'string' as const, description: 'Date (YYYY-MM-DD)' },
127
- time: { type: 'string' as const, description: 'Time (HH:MM)' },
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: ['client', 'professional', 'service', 'date', 'time'],
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
+ }