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