@fayz-ai/plugin-agenda 0.9.0 → 0.10.0
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 +3 -3
- package/dist/bridges/create-financial-bridge.d.ts +1 -1
- package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
- package/dist/bridges/financial-bridge.d.ts.map +1 -0
- package/dist/{chunk-Q2PAWBPR.js → chunk-ISLJXP7O.js} +7 -2
- package/dist/chunk-ISLJXP7O.js.map +1 -0
- package/dist/components/AppointmentModal.d.ts.map +1 -1
- package/dist/components/PaymentStatusBadge.d.ts +1 -1
- package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
- package/dist/context.d.ts.map +1 -0
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/realtime.test.d.ts +2 -0
- package/dist/data/realtime.test.d.ts.map +1 -0
- package/dist/data/registries.d.ts.map +1 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/types.d.ts +7 -0
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2515 -187
- package/dist/index.js.map +1 -1
- package/dist/integrations/google-calendar/conflict.d.ts +25 -0
- package/dist/integrations/google-calendar/conflict.d.ts.map +1 -0
- package/dist/integrations/google-calendar/connectorDef.d.ts +12 -0
- package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
- package/dist/integrations/google-calendar/credentials.d.ts +27 -0
- package/dist/integrations/google-calendar/credentials.d.ts.map +1 -0
- package/dist/integrations/google-calendar/data/supabase.d.ts +10 -2
- package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
- package/dist/integrations/google-calendar/data/tables.d.ts +2 -0
- package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -1
- package/dist/integrations/google-calendar/health.d.ts +23 -0
- package/dist/integrations/google-calendar/health.d.ts.map +1 -0
- package/dist/integrations/google-calendar/health.test.d.ts +2 -0
- package/dist/integrations/google-calendar/health.test.d.ts.map +1 -0
- package/dist/integrations/google-calendar/index.d.ts +6 -0
- package/dist/integrations/google-calendar/index.d.ts.map +1 -1
- package/dist/integrations/google-calendar/migrations/index.d.ts +12 -0
- package/dist/integrations/google-calendar/migrations/index.d.ts.map +1 -0
- package/dist/integrations/google-calendar/panel-data.d.ts +26 -0
- package/dist/integrations/google-calendar/panel-data.d.ts.map +1 -0
- package/dist/integrations/google-calendar/panel-data.test.d.ts +2 -0
- package/dist/integrations/google-calendar/panel-data.test.d.ts.map +1 -0
- package/dist/integrations/google-calendar/types.d.ts +17 -0
- package/dist/integrations/google-calendar/types.d.ts.map +1 -1
- package/dist/lib/booking-events.d.ts +5 -0
- package/dist/lib/booking-events.d.ts.map +1 -0
- package/dist/lib/calendar-events.d.ts +8 -0
- package/dist/lib/calendar-events.d.ts.map +1 -0
- package/dist/lib/calendar-events.test.d.ts +2 -0
- package/dist/lib/calendar-events.test.d.ts.map +1 -0
- package/dist/lib/message-events.d.ts +12 -0
- package/dist/lib/message-events.d.ts.map +1 -0
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
- package/dist/public/data/index.d.ts.map +1 -0
- package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
- package/dist/public/data/supabase.d.ts.map +1 -0
- package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
- package/dist/public/hooks/index.d.ts.map +1 -0
- package/dist/public/index.d.ts +4 -4
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +3 -3
- package/dist/public/index.js.map +1 -1
- package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
- package/dist/public/lib/format.d.ts.map +1 -0
- package/dist/public/views/BookingWidget.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
- package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
- package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
- package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
- package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
- package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/styles/calendar-styles.d.ts.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
- package/dist/views/AgendaPage.d.ts.map +1 -0
- package/dist/views/CalendarView.d.ts.map +1 -1
- package/package.json +9 -11
- package/dist/AgendaContext.d.ts.map +0 -1
- package/dist/AgendaPage.d.ts.map +0 -1
- package/dist/calendar-styles.d.ts.map +0 -1
- package/dist/chunk-Q2PAWBPR.js.map +0 -1
- package/dist/financial-bridge.d.ts.map +0 -1
- package/dist/integrations/google-calendar/GoogleCalendarSettings.d.ts +0 -3
- package/dist/integrations/google-calendar/GoogleCalendarSettings.d.ts.map +0 -1
- package/dist/public/BookingWidget.d.ts.map +0 -1
- package/dist/public/data.d.ts.map +0 -1
- package/dist/public/data.supabase.d.ts.map +0 -1
- package/dist/public/format.d.ts.map +0 -1
- package/dist/public/hooks.d.ts.map +0 -1
- package/dist/public/steps/ContactStep.d.ts.map +0 -1
- package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
- package/dist/public/steps/OverviewStep.d.ts.map +0 -1
- package/dist/public/steps/PaymentStep.d.ts.map +0 -1
- package/dist/registries.d.ts.map +0 -1
- package/src/AgendaContext.tsx +0 -11
- package/src/AgendaPage.tsx +0 -33
- package/src/bridges/create-financial-bridge.ts +0 -296
- package/src/calendar-styles.css +0 -245
- package/src/calendar-styles.ts +0 -285
- package/src/components/AgendaGeneralSettings.tsx +0 -86
- package/src/components/AgendaMobileList.tsx +0 -103
- package/src/components/AppointmentModal.tsx +0 -1050
- package/src/components/AppointmentPopover.tsx +0 -231
- package/src/components/BookingPaymentPanel.tsx +0 -394
- package/src/components/CalendarSidebar.tsx +0 -119
- package/src/components/CalendarToolbar.tsx +0 -86
- package/src/components/EventContextMenu.tsx +0 -125
- package/src/components/FloatingPanel.tsx +0 -123
- package/src/components/MiniCalendar.tsx +0 -120
- package/src/components/PaymentStatusBadge.tsx +0 -62
- package/src/components/StatusBadge.tsx +0 -23
- package/src/config.ts +0 -368
- package/src/data/index.ts +0 -4
- package/src/data/mock.ts +0 -344
- package/src/data/supabase.ts +0 -636
- package/src/data/types.ts +0 -37
- package/src/financial-bridge.ts +0 -96
- package/src/hooks/useAgendaSettings.ts +0 -17
- package/src/index.ts +0 -297
- package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
- package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
- package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
- package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
- package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
- package/src/integrations/google-calendar/connector.ts +0 -20
- package/src/integrations/google-calendar/connectorDef.tsx +0 -185
- package/src/integrations/google-calendar/data/supabase.ts +0 -170
- package/src/integrations/google-calendar/data/tables.ts +0 -12
- package/src/integrations/google-calendar/index.ts +0 -45
- package/src/integrations/google-calendar/integration.test.ts +0 -199
- package/src/integrations/google-calendar/mapping.ts +0 -183
- package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
- package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
- package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
- package/src/integrations/google-calendar/types.ts +0 -142
- package/src/lib/tenant.ts +0 -3
- package/src/locales/en.ts +0 -220
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -220
- package/src/migrations/000b_v_bookings_rename.sql +0 -21
- package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
- package/src/migrations/001_public_booking.sql +0 -296
- package/src/migrations/002_v_bookings_compat.sql +0 -14
- package/src/migrations/003_public_client_kind.sql +0 -175
- package/src/migrations/004_public_booking_assignee.sql +0 -176
- package/src/migrations/005_agent_rpcs.sql +0 -168
- package/src/migrations/006_agent_rpc_localtime.sql +0 -181
- package/src/migrations/007_schedule_exception_precedence.sql +0 -290
- package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
- package/src/migrations/009_public_booking_location.sql +0 -162
- package/src/migrations/010_public_service_curation.sql +0 -31
- package/src/migrations/011_default_assignee.sql +0 -167
- package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
- package/src/migrations/013_normalize_v_appointments.sql +0 -199
- package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
- package/src/migrations/index.ts +0 -2377
- package/src/public/BookingWidget.tsx +0 -324
- package/src/public/components/BrandHeader.tsx +0 -27
- package/src/public/components/ServiceCard.tsx +0 -39
- package/src/public/components/SidebarExtras.tsx +0 -30
- package/src/public/components/StepRailItem.tsx +0 -27
- package/src/public/components/SuccessPanel.tsx +0 -27
- package/src/public/components/SummaryRail.tsx +0 -90
- package/src/public/components/defaults.ts +0 -14
- package/src/public/context.tsx +0 -103
- package/src/public/createPublicBookingPlugin.ts +0 -281
- package/src/public/data.supabase.ts +0 -97
- package/src/public/data.ts +0 -105
- package/src/public/format.ts +0 -96
- package/src/public/hooks.ts +0 -110
- package/src/public/index.ts +0 -69
- package/src/public/steps/ContactStep.tsx +0 -184
- package/src/public/steps/DateTimeStep.tsx +0 -138
- package/src/public/steps/OverviewStep.tsx +0 -81
- package/src/public/steps/PaymentStep.tsx +0 -180
- package/src/public/types.ts +0 -250
- package/src/registries.ts +0 -35
- package/src/store.ts +0 -311
- package/src/types.ts +0 -273
- package/src/views/CalendarView.tsx +0 -1112
- package/src/views/ConfirmationsView.tsx +0 -155
- /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
- /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
- /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
- /package/dist/{calendar-styles.d.ts → styles/calendar-styles.d.ts} +0 -0
|
@@ -1,1050 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
|
-
import { Clock, AlertTriangle, X, User, Briefcase, FileText, CircleDot, Plus, MapPin, Calendar, DollarSign, CheckSquare, Ban, Check, HandCoins } from 'lucide-react'
|
|
4
|
-
|
|
5
|
-
const BOOKING_TYPE_ICONS: Record<string, React.ElementType> = {
|
|
6
|
-
Calendar, CheckSquare, Ban,
|
|
7
|
-
}
|
|
8
|
-
import {
|
|
9
|
-
Modal, ModalContent, ModalTitle,
|
|
10
|
-
} from '@fayz-ai/ui'
|
|
11
|
-
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@fayz-ai/ui'
|
|
12
|
-
import { cn } from '@fayz-ai/ui'
|
|
13
|
-
import { SearchCombobox, type ComboboxOption } from '@fayz-ai/ui'
|
|
14
|
-
import { ContactPicker, type ContactPickerValue } from '@fayz-ai/saas'
|
|
15
|
-
import { BookingPaymentPanel } from './BookingPaymentPanel'
|
|
16
|
-
import { CrudDetailPage } from '@fayz-ai/saas'
|
|
17
|
-
import { CrudFormPage } from '@fayz-ai/saas'
|
|
18
|
-
import { useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
|
|
19
|
-
import { Button } from '@fayz-ai/ui'
|
|
20
|
-
import { useAgendaConfig, useAgendaProvider, useAgendaStore } from '../AgendaContext'
|
|
21
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
22
|
-
import { getCurrentLocale } from '@fayz-ai/core'
|
|
23
|
-
import { isStatusAvailable } from '../types'
|
|
24
|
-
import type { CreateBookingInput, BookingTypeId } from '../types'
|
|
25
|
-
import type { ExternalBookingSource } from '../config'
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Helpers
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
function fmtCurrency(amount: number, locale: string, code: string): string {
|
|
32
|
-
return new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format(amount)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function formatCompactDate(dateStr: string, locale: string): string {
|
|
36
|
-
const d = new Date(dateStr + 'T00:00:00')
|
|
37
|
-
return d.toLocaleDateString(locale === 'pt-BR' ? 'pt-BR' : 'en-US', {
|
|
38
|
-
weekday: 'short', month: 'short', day: 'numeric',
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface ServiceItem { serviceId: string; name: string; durationMinutes: number; price: number }
|
|
43
|
-
interface CancellationReasonItem { id: string; label: string; requiresNotes?: boolean }
|
|
44
|
-
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// Inline editable number — allows clearing, commits on blur/enter
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
function InlineNumber({ value, onChange, min = 0, step = 1, className, disabled, tooltip }: {
|
|
50
|
-
value: number; onChange: (v: number) => void; min?: number; step?: number
|
|
51
|
-
className?: string; disabled?: boolean; tooltip?: string
|
|
52
|
-
}) {
|
|
53
|
-
const [draft, setDraft] = useState(String(value))
|
|
54
|
-
const [focused, setFocused] = useState(false)
|
|
55
|
-
|
|
56
|
-
useEffect(() => { if (!focused) setDraft(String(value)) }, [value, focused])
|
|
57
|
-
|
|
58
|
-
function commit() {
|
|
59
|
-
setFocused(false)
|
|
60
|
-
const v = parseFloat(draft)
|
|
61
|
-
if (!isNaN(v) && v >= min) {
|
|
62
|
-
onChange(v)
|
|
63
|
-
} else {
|
|
64
|
-
setDraft(String(value))
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const input = (
|
|
69
|
-
<input
|
|
70
|
-
type="text"
|
|
71
|
-
inputMode="decimal"
|
|
72
|
-
value={draft}
|
|
73
|
-
onChange={(e) => setDraft(e.target.value)}
|
|
74
|
-
onFocus={(e) => { setFocused(true); e.target.select() }}
|
|
75
|
-
onBlur={commit}
|
|
76
|
-
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit() } }}
|
|
77
|
-
disabled={disabled}
|
|
78
|
-
className={cn(
|
|
79
|
-
'text-right text-[11px] text-muted-foreground bg-transparent border-b border-transparent hover:border-muted-foreground/40 focus:border-foreground focus:outline-none tabular-nums',
|
|
80
|
-
className,
|
|
81
|
-
)}
|
|
82
|
-
/>
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
if (!tooltip) return input
|
|
86
|
-
|
|
87
|
-
return (
|
|
88
|
-
<TooltipProvider delayDuration={400}>
|
|
89
|
-
<Tooltip>
|
|
90
|
-
<TooltipTrigger asChild>{input}</TooltipTrigger>
|
|
91
|
-
<TooltipContent side="top" className="text-xs">{tooltip}</TooltipContent>
|
|
92
|
-
</Tooltip>
|
|
93
|
-
</TooltipProvider>
|
|
94
|
-
)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
// Custom Status Select
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
|
|
101
|
-
function StatusSelect({ value, onChange, statuses, bookingDate }: {
|
|
102
|
-
value: string; onChange: (v: string) => void
|
|
103
|
-
statuses: Array<{ value: string; label: string; color: string; availableWhen?: string }>
|
|
104
|
-
bookingDate?: string
|
|
105
|
-
}) {
|
|
106
|
-
const [open, setOpen] = useState(false)
|
|
107
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
108
|
-
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
109
|
-
const current = statuses.find((s) => s.value === value)
|
|
110
|
-
|
|
111
|
-
useEffect(() => {
|
|
112
|
-
if (!open) return
|
|
113
|
-
const handler = (e: MouseEvent) => {
|
|
114
|
-
const target = e.target as Node
|
|
115
|
-
if (ref.current?.contains(target)) return
|
|
116
|
-
if (dropdownRef.current?.contains(target)) return
|
|
117
|
-
setOpen(false)
|
|
118
|
-
}
|
|
119
|
-
document.addEventListener('mousedown', handler)
|
|
120
|
-
return () => document.removeEventListener('mousedown', handler)
|
|
121
|
-
}, [open])
|
|
122
|
-
|
|
123
|
-
const btnRef = useRef<HTMLButtonElement>(null)
|
|
124
|
-
const [dropRect, setDropRect] = useState<DOMRect | null>(null)
|
|
125
|
-
|
|
126
|
-
function handleOpen() {
|
|
127
|
-
if (btnRef.current) setDropRect(btnRef.current.getBoundingClientRect())
|
|
128
|
-
setOpen((p) => !p)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return (
|
|
132
|
-
<div ref={ref} className="relative flex-1">
|
|
133
|
-
<button ref={btnRef} type="button" onClick={handleOpen}
|
|
134
|
-
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/30 transition-colors text-left w-full">
|
|
135
|
-
{current && <>
|
|
136
|
-
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: current.color }} />
|
|
137
|
-
<span>{current.label}</span>
|
|
138
|
-
</>}
|
|
139
|
-
<svg className="ml-auto h-3 w-3 text-muted-foreground shrink-0" viewBox="0 0 16 16" fill="none">
|
|
140
|
-
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
141
|
-
</svg>
|
|
142
|
-
</button>
|
|
143
|
-
{open && dropRect && createPortal(
|
|
144
|
-
<div ref={dropdownRef} data-modal-passthrough style={{ position: 'fixed', left: dropRect.left, top: dropRect.bottom + 4, width: dropRect.width, zIndex: 9999, pointerEvents: 'auto' }}
|
|
145
|
-
className="fayz-glass-surface rounded-lg border bg-popover shadow-lg py-1 animate-in fade-in zoom-in-95 duration-100">
|
|
146
|
-
{statuses.map((s: any) => {
|
|
147
|
-
const available = !bookingDate || isStatusAvailable(s as any, bookingDate)
|
|
148
|
-
return (
|
|
149
|
-
<button key={s.value} type="button" disabled={!available}
|
|
150
|
-
onClick={() => { if (available) { onChange(s.value); setOpen(false) } }}
|
|
151
|
-
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-sm transition-colors ${
|
|
152
|
-
!available ? 'opacity-30 cursor-not-allowed' : s.value === value ? 'bg-muted/30' : 'hover:bg-muted/50'
|
|
153
|
-
}`}>
|
|
154
|
-
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
|
|
155
|
-
<span>{s.label}</span>
|
|
156
|
-
</button>
|
|
157
|
-
)
|
|
158
|
-
})}
|
|
159
|
-
</div>,
|
|
160
|
-
document.body,
|
|
161
|
-
)}
|
|
162
|
-
</div>
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
167
|
-
// Professional Select — compact inline dropdown with initials
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
|
|
170
|
-
function ProfessionalSelect({ value, onChange, professionals }: {
|
|
171
|
-
value: string; onChange: (v: string) => void
|
|
172
|
-
professionals: Array<{ id: string; name: string }>
|
|
173
|
-
}) {
|
|
174
|
-
const t = useTranslation()
|
|
175
|
-
const [open, setOpen] = useState(false)
|
|
176
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
177
|
-
const current = professionals.find((p) => p.id === value)
|
|
178
|
-
|
|
179
|
-
useEffect(() => {
|
|
180
|
-
if (!open) return
|
|
181
|
-
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
|
182
|
-
document.addEventListener('mousedown', handler)
|
|
183
|
-
return () => document.removeEventListener('mousedown', handler)
|
|
184
|
-
}, [open])
|
|
185
|
-
|
|
186
|
-
return (
|
|
187
|
-
<div ref={ref} className="relative flex-1">
|
|
188
|
-
<button type="button" onClick={() => setOpen((p) => !p)}
|
|
189
|
-
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/30 transition-colors text-left">
|
|
190
|
-
{current ? (
|
|
191
|
-
<span>{current.name}</span>
|
|
192
|
-
) : (
|
|
193
|
-
<span className="text-muted-foreground">{t('agenda.appointment.selectProfessional')}</span>
|
|
194
|
-
)}
|
|
195
|
-
<svg className="ml-auto h-3 w-3 text-muted-foreground shrink-0" viewBox="0 0 16 16" fill="none">
|
|
196
|
-
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
197
|
-
</svg>
|
|
198
|
-
</button>
|
|
199
|
-
{open && (
|
|
200
|
-
<div className="fayz-glass-surface absolute top-full left-0 right-0 z-50 mt-1 rounded-lg border bg-popover shadow-lg py-1 animate-in fade-in zoom-in-95 duration-100">
|
|
201
|
-
{professionals.map((p: any) => (
|
|
202
|
-
<button key={p.id} type="button"
|
|
203
|
-
onClick={() => { onChange(p.id); setOpen(false) }}
|
|
204
|
-
className={`flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50 transition-colors ${p.id === value ? 'bg-muted/30' : ''}`}>
|
|
205
|
-
<span>{p.name}</span>
|
|
206
|
-
</button>
|
|
207
|
-
))}
|
|
208
|
-
</div>
|
|
209
|
-
)}
|
|
210
|
-
</div>
|
|
211
|
-
)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ---------------------------------------------------------------------------
|
|
215
|
-
// Location Select — compact inline dropdown
|
|
216
|
-
// ---------------------------------------------------------------------------
|
|
217
|
-
|
|
218
|
-
function LocationSelect({ value, onChange, locations }: {
|
|
219
|
-
value: string; onChange: (v: string) => void
|
|
220
|
-
locations: Array<{ id: string; name: string }>
|
|
221
|
-
}) {
|
|
222
|
-
const t = useTranslation()
|
|
223
|
-
const [open, setOpen] = useState(false)
|
|
224
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
225
|
-
const current = locations.find((l) => l.id === value)
|
|
226
|
-
|
|
227
|
-
useEffect(() => {
|
|
228
|
-
if (!open) return
|
|
229
|
-
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
|
230
|
-
document.addEventListener('mousedown', handler)
|
|
231
|
-
return () => document.removeEventListener('mousedown', handler)
|
|
232
|
-
}, [open])
|
|
233
|
-
|
|
234
|
-
return (
|
|
235
|
-
<div ref={ref} className="relative flex-1">
|
|
236
|
-
<button type="button" onClick={() => setOpen((p) => !p)}
|
|
237
|
-
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/30 transition-colors text-left">
|
|
238
|
-
{current ? (
|
|
239
|
-
<span>{current.name}</span>
|
|
240
|
-
) : (
|
|
241
|
-
<span className="text-muted-foreground">{t('agenda.appointment.selectLocation')}</span>
|
|
242
|
-
)}
|
|
243
|
-
<svg className="ml-auto h-3 w-3 text-muted-foreground shrink-0" viewBox="0 0 16 16" fill="none">
|
|
244
|
-
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
245
|
-
</svg>
|
|
246
|
-
</button>
|
|
247
|
-
{open && (
|
|
248
|
-
<div className="fayz-glass-surface absolute top-full left-0 right-0 z-50 mt-1 rounded-lg border bg-popover shadow-lg py-1 animate-in fade-in zoom-in-95 duration-100 max-h-48 overflow-y-auto">
|
|
249
|
-
{locations.map((l: any) => (
|
|
250
|
-
<button key={l.id} type="button"
|
|
251
|
-
onClick={() => { onChange(l.id); setOpen(false) }}
|
|
252
|
-
className={`flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50 transition-colors ${l.id === value ? 'bg-muted/30' : ''}`}>
|
|
253
|
-
<span>{l.name}</span>
|
|
254
|
-
</button>
|
|
255
|
-
))}
|
|
256
|
-
</div>
|
|
257
|
-
)}
|
|
258
|
-
</div>
|
|
259
|
-
)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// ---------------------------------------------------------------------------
|
|
263
|
-
// Appointment Modal — Google Calendar-inspired compact layout
|
|
264
|
-
// ---------------------------------------------------------------------------
|
|
265
|
-
|
|
266
|
-
interface Props {
|
|
267
|
-
open: boolean
|
|
268
|
-
mode: 'create' | 'edit'
|
|
269
|
-
bookingId?: string
|
|
270
|
-
prefill?: Partial<CreateBookingInput> & { endsAt?: string }
|
|
271
|
-
initialTab?: string
|
|
272
|
-
externalSource?: ExternalBookingSource
|
|
273
|
-
onClose: () => void
|
|
274
|
-
onCreated?: (bookingId: string, source?: ExternalBookingSource) => void
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export function AppointmentModal({ open, mode, bookingId, prefill, initialTab, externalSource, onClose, onCreated }: Props) {
|
|
278
|
-
const t = useTranslation()
|
|
279
|
-
const locale = getCurrentLocale()
|
|
280
|
-
const config = useAgendaConfig()
|
|
281
|
-
const provider = useAgendaProvider()
|
|
282
|
-
const initialSnapshot = useRef<string>('')
|
|
283
|
-
const createBooking = useAgendaStore((s) => s.createBooking)
|
|
284
|
-
const guardBookings = useLimitGuard('bookings_month')
|
|
285
|
-
const updateBooking = useAgendaStore((s) => s.updateBooking)
|
|
286
|
-
const deleteBooking = useAgendaStore((s) => s.deleteBooking)
|
|
287
|
-
const fetchBookings = useAgendaStore((s) => s.fetchBookings)
|
|
288
|
-
const agendaDateRange = useAgendaStore((s) => s.dateRange)
|
|
289
|
-
const professionals = useAgendaStore((s) => s.professionals)
|
|
290
|
-
const storeLocations = useAgendaStore((s) => s.locations)
|
|
291
|
-
|
|
292
|
-
// --- State ---
|
|
293
|
-
// Default to the first configured booking type (e.g. 'event' in simple mode,
|
|
294
|
-
// 'appointment' otherwise) so single-type agendas resolve their type cleanly.
|
|
295
|
-
const defaultBookingType = (config.bookingTypes[0]?.id ?? 'appointment') as BookingTypeId
|
|
296
|
-
const [loading, setLoading] = useState(mode === 'edit')
|
|
297
|
-
const [bookingType, setBookingType] = useState<BookingTypeId>(prefill?.kind ?? defaultBookingType)
|
|
298
|
-
const [title, setTitle] = useState('')
|
|
299
|
-
const [clientId, setClientId] = useState(prefill?.clientId ?? '')
|
|
300
|
-
const [clientSearch, setClientSearch] = useState('')
|
|
301
|
-
const [clientPhone, setClientPhone] = useState('')
|
|
302
|
-
const [professionalId, setProfessionalId] = useState(prefill?.professionalId ?? '')
|
|
303
|
-
const [locationId, setLocationId] = useState(prefill?.locationId ?? '')
|
|
304
|
-
const [services, setServices] = useState<ServiceItem[]>([])
|
|
305
|
-
const [serviceSearch, setServiceSearch] = useState('')
|
|
306
|
-
const [date, setDate] = useState(() => {
|
|
307
|
-
if (prefill?.startsAt) return prefill.startsAt.slice(0, 10)
|
|
308
|
-
return new Date().toISOString().slice(0, 10)
|
|
309
|
-
})
|
|
310
|
-
const [startTime, setStartTime] = useState(() => {
|
|
311
|
-
if (prefill?.startsAt) {
|
|
312
|
-
const d = new Date(prefill.startsAt)
|
|
313
|
-
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
314
|
-
}
|
|
315
|
-
return '09:00'
|
|
316
|
-
})
|
|
317
|
-
const [status, setStatus] = useState('scheduled')
|
|
318
|
-
const [notes, setNotes] = useState('')
|
|
319
|
-
const [showNotes, setShowNotes] = useState(false)
|
|
320
|
-
const [saving, setSaving] = useState(false)
|
|
321
|
-
const [conflict, setConflict] = useState(false)
|
|
322
|
-
// Client search/quick-create state now lives inside <ContactPicker>.
|
|
323
|
-
const [serviceOptions, setServiceOptions] = useState<ComboboxOption[]>([])
|
|
324
|
-
const [initialServiceOptions, setInitialServiceOptions] = useState<ComboboxOption[]>([])
|
|
325
|
-
const [editingDate, setEditingDate] = useState(false)
|
|
326
|
-
const [editingTime, setEditingTime] = useState(false)
|
|
327
|
-
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
|
328
|
-
const [deleting, setDeleting] = useState(false)
|
|
329
|
-
const [editOrderId, setEditOrderId] = useState<string | null>(null)
|
|
330
|
-
const [editOrderTotal, setEditOrderTotal] = useState(0)
|
|
331
|
-
const [editPaymentStatus, setEditPaymentStatus] = useState<import('../financial-bridge').BookingPaymentStatus>('none')
|
|
332
|
-
const [paymentStatusLoading, setPaymentStatusLoading] = useState(false)
|
|
333
|
-
const [modalTab, setModalTab] = useState<'appointment' | 'client' | 'financial'>('appointment')
|
|
334
|
-
const [clientData, setClientData] = useState<Record<string, any> | null>(null)
|
|
335
|
-
const [clientDataLoading, setClientDataLoading] = useState(false)
|
|
336
|
-
const [clientEditing, setClientEditing] = useState(false)
|
|
337
|
-
const [cancellationReasons, setCancellationReasons] = useState<CancellationReasonItem[]>([])
|
|
338
|
-
const [cancellationReasonId, setCancellationReasonId] = useState('')
|
|
339
|
-
const [cancellationNotes, setCancellationNotes] = useState('')
|
|
340
|
-
const [cancellationReasonsLoading, setCancellationReasonsLoading] = useState(false)
|
|
341
|
-
|
|
342
|
-
// Active booking type config
|
|
343
|
-
const activeType = config.bookingTypes.find((bt) => bt.id === bookingType) ?? config.bookingTypes[0]
|
|
344
|
-
|
|
345
|
-
// Reset form when modal opens
|
|
346
|
-
useEffect(() => {
|
|
347
|
-
if (!open) return
|
|
348
|
-
setClientId(prefill?.clientId ?? ''); setClientSearch(''); setClientPhone('')
|
|
349
|
-
setProfessionalId(prefill?.professionalId ?? ''); setLocationId(prefill?.locationId ?? ''); setServices([])
|
|
350
|
-
setServiceSearch(''); setNotes(''); setStatus('scheduled'); setShowNotes(false)
|
|
351
|
-
setConflict(false); setLoading(mode === 'edit')
|
|
352
|
-
setBookingType(prefill?.kind ?? defaultBookingType); setTitle('')
|
|
353
|
-
setEditingDate(false); setEditingTime(false); setConfirmingDelete(false); setDeleting(false)
|
|
354
|
-
setEditOrderId(null); setEditOrderTotal(0); setEditPaymentStatus('none'); setPaymentStatusLoading(false)
|
|
355
|
-
setClientData(null); setClientDataLoading(false); setClientEditing(false)
|
|
356
|
-
setCancellationReasonId(''); setCancellationNotes(''); setCancellationReasonsLoading(false)
|
|
357
|
-
setModalTab((initialTab as 'appointment' | 'client' | 'financial') || 'appointment')
|
|
358
|
-
if (!prefill?.professionalId && professionals.length === 1) setProfessionalId(professionals[0].id)
|
|
359
|
-
setDate(() => {
|
|
360
|
-
if (prefill?.startsAt) return prefill.startsAt.slice(0, 10)
|
|
361
|
-
return new Date().toISOString().slice(0, 10)
|
|
362
|
-
})
|
|
363
|
-
setStartTime(() => {
|
|
364
|
-
if (prefill?.startsAt) {
|
|
365
|
-
const d = new Date(prefill.startsAt)
|
|
366
|
-
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
367
|
-
}
|
|
368
|
-
return '09:00'
|
|
369
|
-
})
|
|
370
|
-
}, [open])
|
|
371
|
-
|
|
372
|
-
// Edit: load existing
|
|
373
|
-
useEffect(() => {
|
|
374
|
-
if (!open || mode !== 'edit' || !bookingId) return
|
|
375
|
-
setLoading(true)
|
|
376
|
-
provider.getBookingById(bookingId).then((b) => {
|
|
377
|
-
if (!b) { setLoading(false); return }
|
|
378
|
-
setBookingType((b.kind as BookingTypeId) ?? defaultBookingType)
|
|
379
|
-
setTitle(b.clientName ?? '')
|
|
380
|
-
setClientId(b.clientId ?? ''); setClientSearch(b.clientName ?? '')
|
|
381
|
-
setProfessionalId(b.professionalId ?? ''); setLocationId(b.locationId ?? '')
|
|
382
|
-
setDate(b.startsAt.slice(0, 10))
|
|
383
|
-
const d = new Date(b.startsAt)
|
|
384
|
-
setStartTime(`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`)
|
|
385
|
-
setStatus(b.status); setNotes(b.notes ?? '')
|
|
386
|
-
if (b.notes) setShowNotes(true)
|
|
387
|
-
if (b.services) setServices(b.services.map((s: any) => ({
|
|
388
|
-
serviceId: s.serviceId ?? '', name: s.name, durationMinutes: s.durationMinutes, price: s.price,
|
|
389
|
-
})))
|
|
390
|
-
setEditOrderId(b.orderId ?? null)
|
|
391
|
-
setEditOrderTotal(b.orderTotal ?? 0)
|
|
392
|
-
setEditPaymentStatus(b.paymentStatus ?? 'none')
|
|
393
|
-
// Save initial snapshot for dirty check
|
|
394
|
-
const svcSnap = b.services ? b.services.map((s: any) => ({ id: s.serviceId ?? '', dur: s.durationMinutes, price: s.price })) : []
|
|
395
|
-
initialSnapshot.current = JSON.stringify({
|
|
396
|
-
title: b.clientName ?? '',
|
|
397
|
-
clientId: b.clientId ?? '', professionalId: b.professionalId ?? '', locationId: b.locationId ?? '',
|
|
398
|
-
date: b.startsAt.slice(0, 10),
|
|
399
|
-
startTime: `${String(new Date(b.startsAt).getHours()).padStart(2, '0')}:${String(new Date(b.startsAt).getMinutes()).padStart(2, '0')}`,
|
|
400
|
-
status: b.status, notes: b.notes ?? '', cancellationReasonId: '', cancellationNotes: '', services: svcSnap,
|
|
401
|
-
})
|
|
402
|
-
setLoading(false)
|
|
403
|
-
// Resolve payment status from bridge if available
|
|
404
|
-
if (b.orderId && config.financialBridge) {
|
|
405
|
-
setPaymentStatusLoading(true)
|
|
406
|
-
config.financialBridge.resolvePaymentStatuses([b.orderId]).then((map) => {
|
|
407
|
-
const summary = map.get(b.orderId!)
|
|
408
|
-
if (summary) {
|
|
409
|
-
setEditPaymentStatus(summary.status)
|
|
410
|
-
setEditOrderTotal(summary.totalAmount)
|
|
411
|
-
}
|
|
412
|
-
}).catch(() => {}).finally(() => setPaymentStatusLoading(false))
|
|
413
|
-
}
|
|
414
|
-
}).catch(() => setLoading(false))
|
|
415
|
-
}, [open, mode, bookingId])
|
|
416
|
-
|
|
417
|
-
// Fetch client data when client tab is opened
|
|
418
|
-
useEffect(() => {
|
|
419
|
-
if (modalTab !== 'client' || !clientId || !config.contactLookup || clientData) return
|
|
420
|
-
setClientDataLoading(true)
|
|
421
|
-
config.contactLookup.getById(clientId).then((result) => {
|
|
422
|
-
setClientData(result?.data ?? null)
|
|
423
|
-
setClientDataLoading(false)
|
|
424
|
-
}).catch(() => setClientDataLoading(false))
|
|
425
|
-
}, [modalTab, clientId])
|
|
426
|
-
|
|
427
|
-
useEffect(() => {
|
|
428
|
-
if (!open || status !== 'cancelled' || !config.cancellationDetails || cancellationReasons.length > 0) return
|
|
429
|
-
setCancellationReasonsLoading(true)
|
|
430
|
-
config.cancellationDetails.listReasons()
|
|
431
|
-
.then((reasons) => setCancellationReasons(reasons))
|
|
432
|
-
.catch(() => setCancellationReasons([]))
|
|
433
|
-
.finally(() => setCancellationReasonsLoading(false))
|
|
434
|
-
}, [open, status, config.cancellationDetails, cancellationReasons.length])
|
|
435
|
-
|
|
436
|
-
// Fetch initial service list when modal opens
|
|
437
|
-
useEffect(() => {
|
|
438
|
-
if (!open || !config.serviceLookup) return
|
|
439
|
-
const lookup = config.serviceLookup
|
|
440
|
-
const fetch = lookup.list ? () => lookup.list!() : () => lookup.search('')
|
|
441
|
-
fetch().then((results) => {
|
|
442
|
-
const opts = results.map((r: any) => ({ id: r.id, label: r.label, subtitle: r.subtitle, price: r.price, data: r.data }))
|
|
443
|
-
setInitialServiceOptions(opts)
|
|
444
|
-
}).catch(() => setInitialServiceOptions([]))
|
|
445
|
-
}, [open])
|
|
446
|
-
|
|
447
|
-
// Search effects (client search now lives inside <ContactPicker>)
|
|
448
|
-
useEffect(() => {
|
|
449
|
-
if (!config.serviceLookup || serviceSearch.length < 1) { setServiceOptions([]); return }
|
|
450
|
-
const timer = setTimeout(async () => {
|
|
451
|
-
try {
|
|
452
|
-
const results = await config.serviceLookup!.search(serviceSearch)
|
|
453
|
-
setServiceOptions(results.map((r: any) => ({ id: r.id, label: r.label, subtitle: r.subtitle, price: r.price, data: r.data })))
|
|
454
|
-
} catch { setServiceOptions([]) }
|
|
455
|
-
}, 250)
|
|
456
|
-
return () => clearTimeout(timer)
|
|
457
|
-
}, [serviceSearch])
|
|
458
|
-
|
|
459
|
-
// Computed
|
|
460
|
-
const serviceDuration = services.reduce((sum, s) => sum + s.durationMinutes, 0)
|
|
461
|
-
const selectionDuration = useMemo(() => {
|
|
462
|
-
if (!prefill?.startsAt || !prefill?.endsAt) return 0
|
|
463
|
-
return Math.round((new Date(prefill.endsAt).getTime() - new Date(prefill.startsAt).getTime()) / 60000)
|
|
464
|
-
}, [prefill?.startsAt, prefill?.endsAt])
|
|
465
|
-
const totalDuration = serviceDuration > 0 ? serviceDuration : selectionDuration
|
|
466
|
-
const totalPrice = services.reduce((sum, s) => sum + s.price, 0)
|
|
467
|
-
const { currency } = config
|
|
468
|
-
|
|
469
|
-
const endTime = useMemo(() => {
|
|
470
|
-
const [h, m] = startTime.split(':').map(Number)
|
|
471
|
-
const total = h * 60 + m + totalDuration
|
|
472
|
-
return `${String(Math.floor(total / 60) % 24).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`
|
|
473
|
-
}, [startTime, totalDuration])
|
|
474
|
-
|
|
475
|
-
// Conflict check
|
|
476
|
-
useEffect(() => {
|
|
477
|
-
if (!professionalId || totalDuration === 0) { setConflict(false); return }
|
|
478
|
-
const timer = setTimeout(() => {
|
|
479
|
-
provider.checkConflict({
|
|
480
|
-
assigneeId: professionalId, startsAt: `${date}T${startTime}:00`, endsAt: `${date}T${endTime}:00`, excludeBookingId: bookingId,
|
|
481
|
-
}).then(setConflict).catch(() => setConflict(false))
|
|
482
|
-
}, 300)
|
|
483
|
-
return () => clearTimeout(timer)
|
|
484
|
-
}, [professionalId, date, startTime, endTime, bookingId])
|
|
485
|
-
|
|
486
|
-
function addService(opt: ComboboxOption) {
|
|
487
|
-
const duration = (opt.data?.duration_minutes as number) || 60
|
|
488
|
-
setServices((p) => [...p, { serviceId: opt.id, name: opt.label, durationMinutes: duration, price: opt.price ?? 0 }])
|
|
489
|
-
setServiceSearch(''); setServiceOptions([])
|
|
490
|
-
}
|
|
491
|
-
function removeService(idx: number) { setServices((p) => p.filter((_, i) => i !== idx)) }
|
|
492
|
-
|
|
493
|
-
async function handleSubmit(e: React.FormEvent) {
|
|
494
|
-
e.preventDefault()
|
|
495
|
-
if (!canSubmit) return
|
|
496
|
-
// Plan quantity guard (client-side, before the store call): opens the global
|
|
497
|
-
// UpgradeModal and aborts when the monthly booking cap is reached. CREATE only.
|
|
498
|
-
if (mode === 'create' && (await guardBookings()) === 'blocked') return
|
|
499
|
-
setSaving(true)
|
|
500
|
-
try {
|
|
501
|
-
const startsAt = new Date(`${date}T${startTime}:00`).toISOString()
|
|
502
|
-
if (mode === 'create') {
|
|
503
|
-
const newBooking = await createBooking({ kind: bookingType, clientId, professionalId, locationId: locationId || undefined, startsAt, notes: notes || undefined,
|
|
504
|
-
title: activeType.fields.title ? title.trim() : undefined,
|
|
505
|
-
services: services.map((s: any) => ({ serviceId: s.serviceId, name: s.name, durationMinutes: s.durationMinutes, price: s.price })) })
|
|
506
|
-
invalidateLimit('bookings_month')
|
|
507
|
-
onClose()
|
|
508
|
-
onCreated?.(newBooking.id, externalSource)
|
|
509
|
-
} else if (bookingId) {
|
|
510
|
-
await updateBooking(bookingId, {
|
|
511
|
-
clientId, professionalId, locationId: locationId || undefined, startsAt, notes: notes || undefined, status: status as any,
|
|
512
|
-
title: activeType.fields.title ? title.trim() : undefined,
|
|
513
|
-
services: services.map((s: any) => ({ serviceId: s.serviceId, name: s.name, durationMinutes: s.durationMinutes, price: s.price })),
|
|
514
|
-
})
|
|
515
|
-
if (status === 'cancelled' && config.cancellationDetails && cancellationReasonId) {
|
|
516
|
-
await config.cancellationDetails.saveCancellationDetails({
|
|
517
|
-
bookingId,
|
|
518
|
-
reasonId: cancellationReasonId,
|
|
519
|
-
notes: cancellationNotes.trim() || undefined,
|
|
520
|
-
cancelledAt: new Date().toISOString(),
|
|
521
|
-
})
|
|
522
|
-
}
|
|
523
|
-
onClose()
|
|
524
|
-
}
|
|
525
|
-
} catch { /* toast */ }
|
|
526
|
-
setSaving(false)
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
async function handleDelete() {
|
|
530
|
-
if (!bookingId) return
|
|
531
|
-
setDeleting(true)
|
|
532
|
-
try {
|
|
533
|
-
await deleteBooking(bookingId)
|
|
534
|
-
onClose()
|
|
535
|
-
} catch {
|
|
536
|
-
// deleteBooking surfaces its own toast. Return the footer to its normal
|
|
537
|
-
// state (out of the confirm step) so a failure is clear feedback rather
|
|
538
|
-
// than a modal that silently stays stuck on "Confirm delete".
|
|
539
|
-
setConfirmingDelete(false)
|
|
540
|
-
} finally {
|
|
541
|
-
setDeleting(false)
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
const isPaidBooking = editPaymentStatus === 'paid'
|
|
546
|
-
const selectedCancellationReason = cancellationReasons.find((reason) => reason.id === cancellationReasonId)
|
|
547
|
-
const needsCancellationReason = mode === 'edit' && status === 'cancelled' && !!config.cancellationDetails
|
|
548
|
-
const needsCancellationNotes = needsCancellationReason && !!selectedCancellationReason?.requiresNotes
|
|
549
|
-
|
|
550
|
-
// Dirty check — only enable Update when something changed
|
|
551
|
-
const formSnapshot = useMemo(() =>
|
|
552
|
-
JSON.stringify({ title, clientId, professionalId, locationId, date, startTime, status, notes, cancellationReasonId, cancellationNotes, services: services.map((s: any) => ({ id: s.serviceId, dur: s.durationMinutes, price: s.price })) }),
|
|
553
|
-
[title, clientId, professionalId, locationId, date, startTime, status, notes, cancellationReasonId, cancellationNotes, services]
|
|
554
|
-
)
|
|
555
|
-
const isDirty = mode === 'create' || formSnapshot !== initialSnapshot.current
|
|
556
|
-
|
|
557
|
-
const canSubmit = (
|
|
558
|
-
(!activeType.fields.title || title.trim()) &&
|
|
559
|
-
(!activeType.requiresClient || clientId) &&
|
|
560
|
-
(!activeType.fields.professional || professionalId) &&
|
|
561
|
-
(!activeType.requiresServices || services.length > 0) &&
|
|
562
|
-
(!needsCancellationReason || !!cancellationReasonId) &&
|
|
563
|
-
(!needsCancellationNotes || !!cancellationNotes.trim()) &&
|
|
564
|
-
!conflict && !saving
|
|
565
|
-
)
|
|
566
|
-
|
|
567
|
-
const showTabs = config.bookingTypes.length > 1
|
|
568
|
-
|
|
569
|
-
return (
|
|
570
|
-
<Modal open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
|
571
|
-
<ModalContent size="lg" noPadding>
|
|
572
|
-
|
|
573
|
-
{/* ═══ TOP — type tabs + date/time ═══ */}
|
|
574
|
-
<div className="px-5 pt-4 pb-3 shrink-0 space-y-3">
|
|
575
|
-
<ModalTitle className="sr-only">
|
|
576
|
-
{mode === 'create' ? t('agenda.appointment.new') : t('agenda.appointment.edit')}
|
|
577
|
-
</ModalTitle>
|
|
578
|
-
|
|
579
|
-
{/* Booking type tabs (create) or section tabs (edit) */}
|
|
580
|
-
{showTabs && mode === 'create' ? (
|
|
581
|
-
<div className="flex gap-1 border-b fayz-glass-divider">
|
|
582
|
-
{config.bookingTypes.map((bt) => {
|
|
583
|
-
const label = t(`agenda.bookingType.${bt.id}`) || bt.label
|
|
584
|
-
const isActive = bookingType === bt.id
|
|
585
|
-
const BtIcon = BOOKING_TYPE_ICONS[bt.icon] ?? Calendar
|
|
586
|
-
return (
|
|
587
|
-
<button key={bt.id} type="button" onClick={() => setBookingType(bt.id)}
|
|
588
|
-
className={`relative flex items-center gap-1.5 pb-2 px-2 text-xs font-medium transition-colors ${
|
|
589
|
-
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
|
|
590
|
-
}`}>
|
|
591
|
-
<BtIcon className="h-3.5 w-3.5" />
|
|
592
|
-
{label}
|
|
593
|
-
{isActive && (
|
|
594
|
-
<span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-foreground" />
|
|
595
|
-
)}
|
|
596
|
-
</button>
|
|
597
|
-
)
|
|
598
|
-
})}
|
|
599
|
-
</div>
|
|
600
|
-
) : mode === 'edit' && (
|
|
601
|
-
(config.clientEntityDef && clientId) || (config.modules.financial && editOrderId) ? (
|
|
602
|
-
<div className="flex gap-1 border-b fayz-glass-divider">
|
|
603
|
-
{([
|
|
604
|
-
{ id: 'appointment' as const, label: t(`agenda.bookingType.${bookingType}`) || activeType.label, icon: Calendar, show: true },
|
|
605
|
-
{ id: 'client' as const, label: t('agenda.appointment.clientTab'), icon: User, show: !!config.clientEntityDef && !!clientId },
|
|
606
|
-
{ id: 'financial' as const, label: t('agenda.payment.title'), icon: DollarSign, show: config.modules.financial && !!editOrderId },
|
|
607
|
-
]).filter((tab) => tab.show).map((tab) => {
|
|
608
|
-
const isActive = modalTab === tab.id
|
|
609
|
-
return (
|
|
610
|
-
<button key={tab.id} type="button" onClick={() => setModalTab(tab.id)}
|
|
611
|
-
className={`relative flex items-center gap-1.5 pb-2 px-2 text-xs font-medium transition-colors ${
|
|
612
|
-
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
|
|
613
|
-
}`}>
|
|
614
|
-
<tab.icon className="h-3.5 w-3.5" />
|
|
615
|
-
{tab.label}
|
|
616
|
-
{tab.id === 'financial' && editPaymentStatus === 'paid' && (
|
|
617
|
-
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
|
618
|
-
)}
|
|
619
|
-
{tab.id === 'financial' && editPaymentStatus !== 'paid' && (
|
|
620
|
-
(editPaymentStatus !== 'none' || (totalPrice > 0 && date <= new Date().toISOString().slice(0, 10)))
|
|
621
|
-
) && (
|
|
622
|
-
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
|
623
|
-
)}
|
|
624
|
-
{isActive && (
|
|
625
|
-
<span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-foreground" />
|
|
626
|
-
)}
|
|
627
|
-
</button>
|
|
628
|
-
)
|
|
629
|
-
})}
|
|
630
|
-
</div>
|
|
631
|
-
) : (
|
|
632
|
-
<h2 className="text-sm font-semibold">
|
|
633
|
-
{t(`agenda.bookingType.${bookingType}`) || activeType.label}
|
|
634
|
-
</h2>
|
|
635
|
-
)
|
|
636
|
-
)}
|
|
637
|
-
|
|
638
|
-
{/* Date/time + professional row — hidden on financial tab */}
|
|
639
|
-
{modalTab === 'appointment' && (
|
|
640
|
-
<>
|
|
641
|
-
<div className="flex items-center gap-3">
|
|
642
|
-
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
643
|
-
<div className="flex items-center gap-1.5 text-sm flex-1 min-w-0">
|
|
644
|
-
{editingDate ? (
|
|
645
|
-
<input type="date" value={date} autoFocus
|
|
646
|
-
onChange={(e) => { setDate(e.target.value); setEditingDate(false) }}
|
|
647
|
-
onBlur={() => setEditingDate(false)}
|
|
648
|
-
className="bg-transparent text-sm font-medium focus:outline-none border-b border-foreground w-auto shrink-0" />
|
|
649
|
-
) : (
|
|
650
|
-
<button type="button" onClick={() => setEditingDate(true)}
|
|
651
|
-
className="px-0.5 py-0.5 font-medium border-b border-transparent hover:border-foreground transition-colors shrink-0">
|
|
652
|
-
{formatCompactDate(date, locale)}
|
|
653
|
-
</button>
|
|
654
|
-
)}
|
|
655
|
-
{editingTime ? (
|
|
656
|
-
<input type="time" value={startTime} autoFocus
|
|
657
|
-
onChange={(e) => { if (e.target.value) { setStartTime(e.target.value); setEditingTime(false) } }}
|
|
658
|
-
onBlur={() => setEditingTime(false)}
|
|
659
|
-
className="bg-transparent text-xs text-muted-foreground focus:outline-none border-b border-foreground w-auto shrink-0" />
|
|
660
|
-
) : (
|
|
661
|
-
<button type="button" onClick={() => setEditingTime(true)}
|
|
662
|
-
className="px-0.5 py-0.5 text-xs text-muted-foreground border-b border-transparent hover:border-foreground transition-colors shrink-0">
|
|
663
|
-
{startTime}
|
|
664
|
-
</button>
|
|
665
|
-
)}
|
|
666
|
-
<span className="text-[11px] text-muted-foreground/60">—</span>
|
|
667
|
-
<span className="text-[11px] text-muted-foreground/60">{endTime}</span>
|
|
668
|
-
|
|
669
|
-
{/* Professional — right-aligned on the same row */}
|
|
670
|
-
{activeType.fields.professional && (
|
|
671
|
-
<div className="ml-auto shrink-0">
|
|
672
|
-
<ProfessionalSelect value={professionalId} onChange={setProfessionalId} professionals={professionals} />
|
|
673
|
-
</div>
|
|
674
|
-
)}
|
|
675
|
-
</div>
|
|
676
|
-
</div>
|
|
677
|
-
|
|
678
|
-
{conflict && (
|
|
679
|
-
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
|
|
680
|
-
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
|
681
|
-
{t('agenda.appointment.conflict')}
|
|
682
|
-
</div>
|
|
683
|
-
)}
|
|
684
|
-
</>
|
|
685
|
-
)}
|
|
686
|
-
</div>
|
|
687
|
-
|
|
688
|
-
{/* ═══ BODY ═══ */}
|
|
689
|
-
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
690
|
-
|
|
691
|
-
{/* Client tab content — reuses the same CrudDetailPage as /clients/:id */}
|
|
692
|
-
{modalTab === 'client' && clientId && config.clientEntityDef && (
|
|
693
|
-
<div className="flex-1 overflow-y-auto px-5 py-4">
|
|
694
|
-
{clientDataLoading ? (
|
|
695
|
-
<div className="space-y-3 py-2">
|
|
696
|
-
{[...Array(4)].map((_, i) => (
|
|
697
|
-
<div key={i} className="h-8 rounded-lg bg-muted animate-pulse" />
|
|
698
|
-
))}
|
|
699
|
-
</div>
|
|
700
|
-
) : clientData ? (
|
|
701
|
-
clientEditing ? (
|
|
702
|
-
<CrudFormPage
|
|
703
|
-
embedded
|
|
704
|
-
entityDef={config.clientEntityDef}
|
|
705
|
-
mode="edit"
|
|
706
|
-
initialData={clientData}
|
|
707
|
-
namePlural=""
|
|
708
|
-
onCancel={() => setClientEditing(false)}
|
|
709
|
-
onSubmit={async (data) => {
|
|
710
|
-
try {
|
|
711
|
-
const { getSupabaseClientOptional } = await import('@fayz-ai/core')
|
|
712
|
-
const supabase = getSupabaseClientOptional() as any
|
|
713
|
-
if (!supabase) return
|
|
714
|
-
const dbData: Record<string, unknown> = {}
|
|
715
|
-
for (const [k, v] of Object.entries(data)) {
|
|
716
|
-
dbData[k.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase())] = v
|
|
717
|
-
}
|
|
718
|
-
await supabase.from('people').update(dbData).eq('id', clientId)
|
|
719
|
-
setClientData({ ...clientData, ...data })
|
|
720
|
-
setClientEditing(false)
|
|
721
|
-
} catch { /* toast */ }
|
|
722
|
-
}}
|
|
723
|
-
/>
|
|
724
|
-
) : (
|
|
725
|
-
<CrudDetailPage
|
|
726
|
-
embedded
|
|
727
|
-
entityDef={config.clientEntityDef}
|
|
728
|
-
item={clientData}
|
|
729
|
-
namePlural=""
|
|
730
|
-
basePath=""
|
|
731
|
-
onBack={() => setModalTab('appointment')}
|
|
732
|
-
onEdit={() => setClientEditing(true)}
|
|
733
|
-
/>
|
|
734
|
-
)
|
|
735
|
-
) : (
|
|
736
|
-
<p className="text-sm text-muted-foreground py-4 text-center">{t('agenda.appointment.noClientData')}</p>
|
|
737
|
-
)}
|
|
738
|
-
</div>
|
|
739
|
-
)}
|
|
740
|
-
|
|
741
|
-
{/* Financial tab content */}
|
|
742
|
-
{modalTab === 'financial' && editOrderId && config.financialBridge && (
|
|
743
|
-
<div className="flex-1 overflow-y-auto">
|
|
744
|
-
<BookingPaymentPanel
|
|
745
|
-
orderId={editOrderId}
|
|
746
|
-
orderTotal={editOrderTotal}
|
|
747
|
-
services={services.map((s: any) => ({ name: s.name, price: s.price, durationMinutes: s.durationMinutes }))}
|
|
748
|
-
formatCurrency={(v) => fmtCurrency(v, currency.locale, currency.code)}
|
|
749
|
-
inline
|
|
750
|
-
onClose={() => setModalTab('appointment')}
|
|
751
|
-
onPaymentChange={() => {
|
|
752
|
-
config.financialBridge!.getPaymentDetail(editOrderId).then((d) => {
|
|
753
|
-
if (d) { setEditPaymentStatus(d.status); setEditOrderTotal(d.totalAmount) }
|
|
754
|
-
}).catch(() => {})
|
|
755
|
-
// Propagate to the calendar card + popover badge (booking.paymentStatus
|
|
756
|
-
// is ledger-derived) so they update without a page refresh.
|
|
757
|
-
if (agendaDateRange.start) fetchBookings(agendaDateRange)
|
|
758
|
-
}}
|
|
759
|
-
/>
|
|
760
|
-
</div>
|
|
761
|
-
)}
|
|
762
|
-
|
|
763
|
-
{/* Appointment tab content */}
|
|
764
|
-
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0 overflow-hidden" style={{ display: modalTab === 'appointment' ? undefined : 'none' }}>
|
|
765
|
-
<div className="border-t fayz-glass-divider px-5 py-3 space-y-1 flex-1 overflow-y-auto">
|
|
766
|
-
|
|
767
|
-
{/* Loading skeleton */}
|
|
768
|
-
{loading && (
|
|
769
|
-
<div className="space-y-2 py-2">
|
|
770
|
-
{[...Array(4)].map((_, i) => (
|
|
771
|
-
<div key={i} className="flex items-center gap-3 py-1.5">
|
|
772
|
-
<div className="h-4 w-4 rounded bg-muted animate-pulse shrink-0" />
|
|
773
|
-
<div className="h-8 flex-1 rounded-lg bg-muted animate-pulse" />
|
|
774
|
-
</div>
|
|
775
|
-
))}
|
|
776
|
-
</div>
|
|
777
|
-
)}
|
|
778
|
-
|
|
779
|
-
{!loading && <>
|
|
780
|
-
|
|
781
|
-
{/* Title — free-text event name (simple / Google-Calendar-style events) */}
|
|
782
|
-
{activeType.fields.title && (
|
|
783
|
-
<div className="flex items-center gap-3 py-1">
|
|
784
|
-
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
785
|
-
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)}
|
|
786
|
-
placeholder={t('agenda.appointment.addTitle')} autoFocus={mode === 'create'}
|
|
787
|
-
className="w-full flex-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2.5 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
788
|
-
</div>
|
|
789
|
-
)}
|
|
790
|
-
|
|
791
|
-
{/* Client */}
|
|
792
|
-
{activeType.fields.client && (
|
|
793
|
-
<div className="flex items-center gap-3 py-1">
|
|
794
|
-
<User className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
795
|
-
<div className="flex-1">
|
|
796
|
-
{/* Shared find-or-create contact flow (@fayz-ai/saas). The
|
|
797
|
-
quick-create that used to live inline here now writes the
|
|
798
|
-
SAME people(+clients) rows for every plugin — see
|
|
799
|
-
`createPerson`. `clientKind`/`clientExtensionTable` keep the
|
|
800
|
-
vertical's discriminator and extension table.
|
|
801
|
-
`variant="inline"` keeps this modal's grammar: an icon
|
|
802
|
-
column and borderless controls (like Scheduled / Add
|
|
803
|
-
notes). Same component and same flow as the conversations
|
|
804
|
-
composer — only the input's chrome differs. */}
|
|
805
|
-
<ContactPicker
|
|
806
|
-
value={clientId || clientSearch ? { id: clientId || undefined, name: clientSearch, phone: clientPhone } : null}
|
|
807
|
-
onChange={(contact) => {
|
|
808
|
-
setClientId(contact?.id ?? '')
|
|
809
|
-
setClientSearch(contact?.name ?? '')
|
|
810
|
-
setClientPhone(contact?.phone ?? '')
|
|
811
|
-
}}
|
|
812
|
-
kind={config.clientKind}
|
|
813
|
-
extensionTable="clients"
|
|
814
|
-
lookup={config.contactLookup}
|
|
815
|
-
clearable={!isPaidBooking}
|
|
816
|
-
variant="inline"
|
|
817
|
-
placeholder={t('agenda.appointment.searchClient')}
|
|
818
|
-
createLabel={t('agenda.appointment.newClient')}
|
|
819
|
-
hint={
|
|
820
|
-
<p className="text-[11px] text-warning">
|
|
821
|
-
{t('agenda.appointment.selectClientHint')}
|
|
822
|
-
</p>
|
|
823
|
-
}
|
|
824
|
-
/>
|
|
825
|
-
</div>
|
|
826
|
-
</div>
|
|
827
|
-
)}
|
|
828
|
-
|
|
829
|
-
{/* Location */}
|
|
830
|
-
{activeType.fields.location && config.modules.locationSelection && (storeLocations.length > 0 || config.locations.length > 0) && (
|
|
831
|
-
<div className="flex items-center gap-3 py-1">
|
|
832
|
-
<MapPin className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
833
|
-
<LocationSelect
|
|
834
|
-
value={locationId}
|
|
835
|
-
onChange={setLocationId}
|
|
836
|
-
locations={storeLocations.length > 0 ? storeLocations : config.locations}
|
|
837
|
-
/>
|
|
838
|
-
</div>
|
|
839
|
-
)}
|
|
840
|
-
|
|
841
|
-
{/* Services */}
|
|
842
|
-
{activeType.fields.services && (
|
|
843
|
-
<div className="flex items-start gap-3 py-1">
|
|
844
|
-
<Briefcase className="h-4 w-4 mt-1.5 text-muted-foreground shrink-0" />
|
|
845
|
-
<div className="flex-1">
|
|
846
|
-
{services.length > 0 && (
|
|
847
|
-
<div className="space-y-0.5 mb-1">
|
|
848
|
-
{services.map((svc, idx) => (
|
|
849
|
-
<div key={idx} className="flex items-center rounded-md bg-muted/30 px-2.5 py-1.5 text-sm">
|
|
850
|
-
<span className="flex-1 truncate min-w-0">{svc.name}</span>
|
|
851
|
-
<div className="flex items-center gap-0.5 shrink-0 ml-3">
|
|
852
|
-
<InlineNumber
|
|
853
|
-
value={svc.durationMinutes}
|
|
854
|
-
onChange={(v) => setServices((p) => p.map((s, i) => i === idx ? { ...s, durationMinutes: Math.round(v) } : s))}
|
|
855
|
-
min={5}
|
|
856
|
-
step={5}
|
|
857
|
-
className="w-10 shrink-0"
|
|
858
|
-
disabled={isPaidBooking}
|
|
859
|
-
tooltip={t('agenda.appointment.duration')}
|
|
860
|
-
/>
|
|
861
|
-
<span className="text-[11px] text-muted-foreground">min</span>
|
|
862
|
-
</div>
|
|
863
|
-
<div className="flex items-center gap-0.5 shrink-0 ml-3">
|
|
864
|
-
<span className="text-[11px] text-muted-foreground">{currency.symbol}</span>
|
|
865
|
-
<InlineNumber
|
|
866
|
-
value={svc.price}
|
|
867
|
-
onChange={(v) => setServices((p) => p.map((s, i) => i === idx ? { ...s, price: v } : s))}
|
|
868
|
-
min={0}
|
|
869
|
-
step={0.01}
|
|
870
|
-
className="w-14 shrink-0"
|
|
871
|
-
disabled={isPaidBooking}
|
|
872
|
-
tooltip={t('agenda.appointment.price')}
|
|
873
|
-
/>
|
|
874
|
-
</div>
|
|
875
|
-
{!isPaidBooking && (
|
|
876
|
-
<button type="button" onClick={() => removeService(idx)} className="p-0.5 ml-2 text-muted-foreground hover:text-destructive shrink-0">
|
|
877
|
-
<X className="h-3 w-3" />
|
|
878
|
-
</button>
|
|
879
|
-
)}
|
|
880
|
-
</div>
|
|
881
|
-
))}
|
|
882
|
-
</div>
|
|
883
|
-
)}
|
|
884
|
-
|
|
885
|
-
{!isPaidBooking && <SearchCombobox
|
|
886
|
-
value={serviceSearch}
|
|
887
|
-
onChange={setServiceSearch}
|
|
888
|
-
onSelect={addService}
|
|
889
|
-
options={serviceOptions}
|
|
890
|
-
placeholder={t('agenda.appointment.searchService')}
|
|
891
|
-
onBlurEmpty={() => {}}
|
|
892
|
-
onFocus={() => { if (!serviceSearch && serviceOptions.length === 0) setServiceOptions(initialServiceOptions) }}
|
|
893
|
-
renderRight={(opt) => {
|
|
894
|
-
const dur = (opt.data?.duration_minutes as number) || 0
|
|
895
|
-
const hasPrice = opt.price != null
|
|
896
|
-
if (!hasPrice && !dur) return null
|
|
897
|
-
return (
|
|
898
|
-
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
|
899
|
-
{hasPrice && fmtCurrency(opt.price!, currency.locale, currency.code)}
|
|
900
|
-
{hasPrice && dur > 0 && ' · '}
|
|
901
|
-
{dur > 0 && `${dur}min`}
|
|
902
|
-
</span>
|
|
903
|
-
)
|
|
904
|
-
}}
|
|
905
|
-
minimal
|
|
906
|
-
inlineLabel={services.length > 0 ? t('agenda.appointment.addService') : t('agenda.appointment.addServiceFirst')}
|
|
907
|
-
inlineIcon={services.length > 0 ? 'plus' : 'search'}
|
|
908
|
-
/>}
|
|
909
|
-
</div>
|
|
910
|
-
</div>
|
|
911
|
-
)}
|
|
912
|
-
|
|
913
|
-
{/* Status */}
|
|
914
|
-
{activeType.fields.status && (
|
|
915
|
-
<div className="flex items-center gap-3 py-1">
|
|
916
|
-
<CircleDot className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
917
|
-
<StatusSelect value={status} onChange={setStatus} statuses={config.statuses} bookingDate={date} />
|
|
918
|
-
</div>
|
|
919
|
-
)}
|
|
920
|
-
|
|
921
|
-
{needsCancellationReason && (
|
|
922
|
-
<div className="flex items-start gap-3 py-1">
|
|
923
|
-
<Ban className="h-4 w-4 mt-2 text-muted-foreground shrink-0" />
|
|
924
|
-
<div className="flex-1 space-y-2">
|
|
925
|
-
<select
|
|
926
|
-
value={cancellationReasonId}
|
|
927
|
-
onChange={(event) => setCancellationReasonId(event.target.value)}
|
|
928
|
-
disabled={cancellationReasonsLoading}
|
|
929
|
-
className="w-full rounded-input border border-input bg-card px-2.5 py-1.5 text-sm shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
930
|
-
>
|
|
931
|
-
<option value="">{cancellationReasonsLoading ? t('agenda.cancellation.loadingReasons') : t('agenda.cancellation.selectReason')}</option>
|
|
932
|
-
{cancellationReasons.map((reason) => (
|
|
933
|
-
<option key={reason.id} value={reason.id}>
|
|
934
|
-
{reason.label}{reason.requiresNotes ? ' *' : ''}
|
|
935
|
-
</option>
|
|
936
|
-
))}
|
|
937
|
-
</select>
|
|
938
|
-
<textarea
|
|
939
|
-
value={cancellationNotes}
|
|
940
|
-
onChange={(event) => setCancellationNotes(event.target.value)}
|
|
941
|
-
placeholder={needsCancellationNotes ? t('agenda.cancellation.notesRequired') : t('agenda.cancellation.notesOptional')}
|
|
942
|
-
rows={2}
|
|
943
|
-
className="w-full rounded-input border border-input bg-card px-2.5 py-1.5 text-sm resize-none shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
944
|
-
/>
|
|
945
|
-
</div>
|
|
946
|
-
</div>
|
|
947
|
-
)}
|
|
948
|
-
|
|
949
|
-
{/* Notes — progressive disclosure */}
|
|
950
|
-
{!showNotes ? (
|
|
951
|
-
<div className="flex items-center gap-3 py-1">
|
|
952
|
-
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
953
|
-
<button type="button" onClick={() => setShowNotes(true)}
|
|
954
|
-
className="text-sm text-muted-foreground hover:text-foreground transition-colors text-left py-1.5 pl-2">
|
|
955
|
-
{t('agenda.appointment.addNotes')}
|
|
956
|
-
</button>
|
|
957
|
-
</div>
|
|
958
|
-
) : (
|
|
959
|
-
<div className="flex items-start gap-3 py-1">
|
|
960
|
-
<FileText className="h-4 w-4 mt-2 text-muted-foreground shrink-0" />
|
|
961
|
-
<textarea value={notes} onChange={(e) => setNotes(e.target.value)}
|
|
962
|
-
placeholder={t('agenda.appointment.addDescription')} rows={2} autoFocus
|
|
963
|
-
className="w-full flex-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2.5 py-1.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
964
|
-
</div>
|
|
965
|
-
)}
|
|
966
|
-
|
|
967
|
-
</>}
|
|
968
|
-
</div>
|
|
969
|
-
|
|
970
|
-
{/* Appointment footer — inside form for submit */}
|
|
971
|
-
{modalTab === 'appointment' && !clientEditing && (
|
|
972
|
-
<div className="px-5 py-2.5 border-t fayz-glass-divider bg-transparent shrink-0 sm:rounded-b-2xl">
|
|
973
|
-
{confirmingDelete ? (
|
|
974
|
-
<div className="flex items-center justify-between">
|
|
975
|
-
<span className="text-xs text-destructive font-medium">{t('agenda.appointment.deleteConfirm')}</span>
|
|
976
|
-
<div className="flex items-center gap-2">
|
|
977
|
-
<Button type="button" variant="outline" size="sm" onClick={() => setConfirmingDelete(false)}>
|
|
978
|
-
{t('agenda.appointment.cancel')}
|
|
979
|
-
</Button>
|
|
980
|
-
<Button type="button" variant="destructive" size="sm" onClick={handleDelete} disabled={deleting}>
|
|
981
|
-
{deleting ? t('agenda.appointment.deleting') : t('agenda.appointment.confirmDelete')}
|
|
982
|
-
</Button>
|
|
983
|
-
</div>
|
|
984
|
-
</div>
|
|
985
|
-
) : (
|
|
986
|
-
<div className="flex items-center justify-between">
|
|
987
|
-
<div className="flex items-center gap-3">
|
|
988
|
-
{mode === 'edit' && (
|
|
989
|
-
<button type="button" onClick={() => setConfirmingDelete(true)} className="text-xs text-destructive font-medium hover:underline">{t('agenda.appointment.delete')}</button>
|
|
990
|
-
)}
|
|
991
|
-
{services.length > 0 && (
|
|
992
|
-
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
993
|
-
<Clock className="h-3 w-3" />
|
|
994
|
-
{totalDuration}min
|
|
995
|
-
{config.modules.financial && totalPrice > 0 && mode === 'edit' ? (
|
|
996
|
-
paymentStatusLoading ? (
|
|
997
|
-
<span className="h-4 w-16 rounded-full bg-muted animate-pulse" />
|
|
998
|
-
) : isPaidBooking ? (
|
|
999
|
-
<button type="button" onClick={(e) => { e.stopPropagation(); setModalTab('financial') }}
|
|
1000
|
-
className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-[10px] font-medium text-success hover:bg-success/20 transition-colors">
|
|
1001
|
-
<Check className="h-2.5 w-2.5" />
|
|
1002
|
-
{fmtCurrency(totalPrice, currency.locale, currency.code)} · {t('agenda.payment.paid')}
|
|
1003
|
-
</button>
|
|
1004
|
-
) : date <= new Date().toISOString().slice(0, 10) ? (
|
|
1005
|
-
<button type="button" onClick={(e) => { e.stopPropagation(); setModalTab('financial') }}
|
|
1006
|
-
className="inline-flex items-center gap-1 rounded-full bg-warning/10 px-2 py-0.5 text-[10px] font-medium text-warning hover:bg-warning/20 transition-colors">
|
|
1007
|
-
<HandCoins className="h-3 w-3" />
|
|
1008
|
-
{fmtCurrency(totalPrice, currency.locale, currency.code)} · {t('agenda.payment.collect')}
|
|
1009
|
-
</button>
|
|
1010
|
-
) : (
|
|
1011
|
-
<span>· {fmtCurrency(totalPrice, currency.locale, currency.code)}</span>
|
|
1012
|
-
)
|
|
1013
|
-
) : (
|
|
1014
|
-
<span>· {fmtCurrency(totalPrice, currency.locale, currency.code)}</span>
|
|
1015
|
-
)}
|
|
1016
|
-
</span>
|
|
1017
|
-
)}
|
|
1018
|
-
</div>
|
|
1019
|
-
<div className="flex items-center gap-2">
|
|
1020
|
-
<Button type="button" variant="outline" size="sm" onClick={onClose} className="hidden sm:inline-flex">
|
|
1021
|
-
{t('agenda.appointment.cancel')}
|
|
1022
|
-
</Button>
|
|
1023
|
-
<Button type="submit" variant="default" size="sm" disabled={!canSubmit || !isDirty}>
|
|
1024
|
-
{saving ? t('agenda.appointment.saving') : mode === 'create' ? t('agenda.appointment.save') : t('agenda.appointment.update')}
|
|
1025
|
-
</Button>
|
|
1026
|
-
</div>
|
|
1027
|
-
</div>
|
|
1028
|
-
)}
|
|
1029
|
-
</div>
|
|
1030
|
-
)}
|
|
1031
|
-
</form>
|
|
1032
|
-
|
|
1033
|
-
{/* Non-appointment tab footer — outside form */}
|
|
1034
|
-
{modalTab !== 'appointment' && !clientEditing && (
|
|
1035
|
-
<div className="px-5 py-2.5 border-t fayz-glass-divider bg-transparent shrink-0 sm:rounded-b-2xl">
|
|
1036
|
-
<div className="flex items-center">
|
|
1037
|
-
<button type="button" onClick={() => { setModalTab('appointment'); setClientEditing(false) }}
|
|
1038
|
-
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors">
|
|
1039
|
-
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none"><path d="M10 12L6 8l4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
1040
|
-
{t('agenda.payment.backToAppointment')}
|
|
1041
|
-
</button>
|
|
1042
|
-
</div>
|
|
1043
|
-
</div>
|
|
1044
|
-
)}
|
|
1045
|
-
</div>
|
|
1046
|
-
</ModalContent>
|
|
1047
|
-
|
|
1048
|
-
</Modal>
|
|
1049
|
-
)
|
|
1050
|
-
}
|