@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,119 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Filter, X } from 'lucide-react'
|
|
3
|
+
import { useAgendaConfig, useAgendaStore } from '../AgendaContext'
|
|
4
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
+
import type { Professional } from '../types'
|
|
6
|
+
|
|
7
|
+
export function CalendarSidebar() {
|
|
8
|
+
const t = useTranslation()
|
|
9
|
+
const config = useAgendaConfig()
|
|
10
|
+
const professionals = useAgendaStore((s) => s.professionals)
|
|
11
|
+
const selectedProfIds = useAgendaStore((s) => s.selectedProfessionalIds)
|
|
12
|
+
const selectedStatuses = useAgendaStore((s) => s.selectedStatuses)
|
|
13
|
+
const selectedLocationId = useAgendaStore((s) => s.selectedLocationId)
|
|
14
|
+
const setFilters = useAgendaStore((s) => s.setFilters)
|
|
15
|
+
|
|
16
|
+
const hasFilters = selectedProfIds.length > 0 || selectedStatuses.length > 0 || !!selectedLocationId
|
|
17
|
+
|
|
18
|
+
function toggleProfessional(id: string) {
|
|
19
|
+
const next = selectedProfIds.includes(id)
|
|
20
|
+
? selectedProfIds.filter((p) => p !== id)
|
|
21
|
+
: [...selectedProfIds, id]
|
|
22
|
+
setFilters({ professionalIds: next })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toggleStatus(value: string) {
|
|
26
|
+
const next = selectedStatuses.includes(value)
|
|
27
|
+
? selectedStatuses.filter((s) => s !== value)
|
|
28
|
+
: [...selectedStatuses, value]
|
|
29
|
+
setFilters({ statuses: next })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function clearFilters() {
|
|
33
|
+
setFilters({ professionalIds: [], statuses: [], locationId: null })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="space-y-6">
|
|
38
|
+
{/* Filters header */}
|
|
39
|
+
<div className="flex items-center justify-between">
|
|
40
|
+
<div className="flex items-center gap-2 text-sm font-medium">
|
|
41
|
+
<Filter className="h-4 w-4" />
|
|
42
|
+
{config.labels.filters}
|
|
43
|
+
</div>
|
|
44
|
+
{hasFilters && (
|
|
45
|
+
<button onClick={clearFilters} className="text-xs text-muted-foreground hover:text-foreground">
|
|
46
|
+
<X className="h-3 w-3" />
|
|
47
|
+
</button>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{/* Professional filter */}
|
|
52
|
+
<div>
|
|
53
|
+
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
54
|
+
{t('agenda.sidebar.agendas')}
|
|
55
|
+
</label>
|
|
56
|
+
<div className="mt-2 space-y-1">
|
|
57
|
+
{professionals.map((prof) => (
|
|
58
|
+
<label key={prof.id} className="flex items-center gap-2 cursor-pointer rounded px-2 py-1 hover:bg-muted/50">
|
|
59
|
+
<input
|
|
60
|
+
type="checkbox"
|
|
61
|
+
checked={selectedProfIds.length === 0 || selectedProfIds.includes(prof.id)}
|
|
62
|
+
onChange={() => toggleProfessional(prof.id)}
|
|
63
|
+
className="h-3.5 w-3.5 rounded border-muted-foreground/30"
|
|
64
|
+
/>
|
|
65
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
66
|
+
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
|
67
|
+
{prof.name.split(' ').map((n) => n[0]).join('').slice(0, 2)}
|
|
68
|
+
</div>
|
|
69
|
+
<span className="text-sm truncate">{prof.name}</span>
|
|
70
|
+
</div>
|
|
71
|
+
</label>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Status filter */}
|
|
77
|
+
<div>
|
|
78
|
+
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
79
|
+
{t('agenda.sidebar.status')}
|
|
80
|
+
</label>
|
|
81
|
+
<div className="mt-2 space-y-1">
|
|
82
|
+
{config.statuses.map((status) => (
|
|
83
|
+
<label key={status.value} className="flex items-center gap-2 cursor-pointer rounded px-2 py-1 hover:bg-muted/50">
|
|
84
|
+
<input
|
|
85
|
+
type="checkbox"
|
|
86
|
+
checked={selectedStatuses.length === 0 || selectedStatuses.includes(status.value)}
|
|
87
|
+
onChange={() => toggleStatus(status.value)}
|
|
88
|
+
className="h-3.5 w-3.5 rounded border-muted-foreground/30"
|
|
89
|
+
/>
|
|
90
|
+
<div className="flex items-center gap-2">
|
|
91
|
+
<div className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: status.color }} />
|
|
92
|
+
<span className="text-sm">{status.label}</span>
|
|
93
|
+
</div>
|
|
94
|
+
</label>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Location filter */}
|
|
100
|
+
{config.locations.length > 1 && (
|
|
101
|
+
<div>
|
|
102
|
+
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
103
|
+
{t('agenda.sidebar.location')}
|
|
104
|
+
</label>
|
|
105
|
+
<select
|
|
106
|
+
value={selectedLocationId ?? ''}
|
|
107
|
+
onChange={(e) => setFilters({ locationId: e.target.value || null })}
|
|
108
|
+
className="mt-2 block w-full rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2 py-1.5 text-sm"
|
|
109
|
+
>
|
|
110
|
+
<option value="">{t('agenda.sidebar.allLocations')}</option>
|
|
111
|
+
{config.locations.map((loc) => (
|
|
112
|
+
<option key={loc.id} value={loc.id}>{loc.name}</option>
|
|
113
|
+
))}
|
|
114
|
+
</select>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { ChevronLeft, ChevronRight, Plus, Calendar } from 'lucide-react'
|
|
3
|
+
import { SegmentedControl } from '@fayz-ai/ui'
|
|
4
|
+
import { useAgendaConfig } from '../AgendaContext'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
6
|
+
|
|
7
|
+
interface CalendarToolbarProps {
|
|
8
|
+
title: string
|
|
9
|
+
onPrev: () => void
|
|
10
|
+
onNext: () => void
|
|
11
|
+
onToday: () => void
|
|
12
|
+
onViewChange: (view: string) => void
|
|
13
|
+
currentView: string
|
|
14
|
+
onNewAppointment: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const VIEW_OPTION_KEYS = [
|
|
18
|
+
{ key: 'dayGridMonth', labelKey: 'agenda.calendar.viewMonth' },
|
|
19
|
+
{ key: 'resourceTimeGridWeek', labelKey: 'agenda.calendar.viewWeek' },
|
|
20
|
+
{ key: 'resourceTimeGridDay', labelKey: 'agenda.calendar.viewDay' },
|
|
21
|
+
{ key: 'listWeek', labelKey: 'agenda.calendar.viewAgenda' },
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
export function CalendarToolbar({
|
|
25
|
+
title, onPrev, onNext, onToday, onViewChange, currentView, onNewAppointment,
|
|
26
|
+
}: CalendarToolbarProps) {
|
|
27
|
+
const config = useAgendaConfig()
|
|
28
|
+
const t = useTranslation()
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex items-center justify-between pb-4">
|
|
32
|
+
{/* Left: nav controls + date */}
|
|
33
|
+
<div className="flex items-center gap-3">
|
|
34
|
+
<div className="flex items-center gap-1">
|
|
35
|
+
<button
|
|
36
|
+
onClick={onPrev}
|
|
37
|
+
className="flex h-8 items-center justify-center rounded-lg border px-2 text-sm hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors"
|
|
38
|
+
>
|
|
39
|
+
<ChevronLeft className="h-4 w-4" />
|
|
40
|
+
<span className="hidden sm:inline">{t('agenda.toolbar.previous')}</span>
|
|
41
|
+
</button>
|
|
42
|
+
<button
|
|
43
|
+
onClick={onToday}
|
|
44
|
+
className="flex h-8 items-center justify-center rounded-lg border px-3 text-sm font-medium hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors"
|
|
45
|
+
>
|
|
46
|
+
{t('agenda.toolbar.today')}
|
|
47
|
+
</button>
|
|
48
|
+
<button
|
|
49
|
+
onClick={onNext}
|
|
50
|
+
className="flex h-8 items-center justify-center rounded-lg border px-2 text-sm hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors"
|
|
51
|
+
>
|
|
52
|
+
<span className="hidden sm:inline">{t('agenda.toolbar.next')}</span>
|
|
53
|
+
<ChevronRight className="h-4 w-4" />
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div className="flex items-center gap-2">
|
|
58
|
+
<Calendar className="h-4 w-4 text-muted-foreground" />
|
|
59
|
+
<h2 className="text-lg font-semibold">{title}</h2>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{/* Right: view switcher + new */}
|
|
64
|
+
<div className="flex items-center gap-3">
|
|
65
|
+
<SegmentedControl
|
|
66
|
+
size="md"
|
|
67
|
+
options={VIEW_OPTION_KEYS.map((opt) => ({ value: opt.key, label: t(opt.labelKey) }))}
|
|
68
|
+
value={currentView}
|
|
69
|
+
onChange={onViewChange}
|
|
70
|
+
aria-label="Calendar view"
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<button
|
|
74
|
+
onClick={onNewAppointment}
|
|
75
|
+
className="flex items-center gap-2 rounded-lg bg-primary border border-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors"
|
|
76
|
+
>
|
|
77
|
+
<Plus className="h-4 w-4" />
|
|
78
|
+
<span className="hidden sm:inline">{config.labels.newAppointment}</span>
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React, { useRef, useState, useMemo } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { Pencil, Trash2, Check } from 'lucide-react'
|
|
4
|
+
import { FloatingPanel } from './FloatingPanel'
|
|
5
|
+
import { useAgendaConfig, useAgendaStore } from '../AgendaContext'
|
|
6
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
7
|
+
import { isStatusAvailable } from '../types'
|
|
8
|
+
import type { CalendarBooking } from '../types'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Tooltip — portaled to body
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
function Tooltip({ anchor, label }: { anchor: DOMRect; label: string }) {
|
|
15
|
+
return createPortal(
|
|
16
|
+
<div style={{ position: 'fixed', left: anchor.left + anchor.width / 2, top: anchor.bottom + 6, transform: 'translateX(-50%)', zIndex: 120 }}
|
|
17
|
+
className="pointer-events-none">
|
|
18
|
+
<div className="rounded-md bg-foreground text-background px-2 py-0.5 text-[11px] font-medium whitespace-nowrap shadow-lg">
|
|
19
|
+
{label}
|
|
20
|
+
</div>
|
|
21
|
+
</div>,
|
|
22
|
+
document.body,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Status dot with portaled tooltip
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function StatusDot({ color, label, isActive, disabled, onClick }: {
|
|
31
|
+
color: string; label: string; isActive: boolean; disabled?: boolean; onClick: () => void
|
|
32
|
+
}) {
|
|
33
|
+
const [hover, setHover] = useState(false)
|
|
34
|
+
const btnRef = useRef<HTMLButtonElement>(null)
|
|
35
|
+
const [rect, setRect] = useState<DOMRect | null>(null)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<button ref={btnRef} type="button" onClick={disabled ? undefined : onClick} disabled={disabled}
|
|
40
|
+
onMouseEnter={() => { setHover(true); if (btnRef.current) setRect(btnRef.current.getBoundingClientRect()) }}
|
|
41
|
+
onMouseLeave={() => setHover(false)}
|
|
42
|
+
className={`flex h-7 w-7 items-center justify-center rounded-full transition-all ${
|
|
43
|
+
disabled ? 'opacity-25 cursor-not-allowed' : isActive ? 'ring-2 ring-offset-2 ring-offset-popover' : 'hover:scale-110'
|
|
44
|
+
}`}
|
|
45
|
+
style={{ backgroundColor: color, ['--tw-ring-color' as any]: isActive ? color : undefined }}>
|
|
46
|
+
{isActive && <Check className="h-3.5 w-3.5 text-white" strokeWidth={2.5} />}
|
|
47
|
+
</button>
|
|
48
|
+
{hover && rect && <Tooltip anchor={rect} label={label} />}
|
|
49
|
+
</>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Context Menu
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
interface Props {
|
|
58
|
+
booking: CalendarBooking
|
|
59
|
+
position: { x: number; y: number }
|
|
60
|
+
onClose: () => void
|
|
61
|
+
onEdit: (id: string) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function EventContextMenu({ booking, position, onClose, onEdit }: Props) {
|
|
65
|
+
const t = useTranslation()
|
|
66
|
+
const config = useAgendaConfig()
|
|
67
|
+
const updateStatus = useAgendaStore((s) => s.updateBookingStatus)
|
|
68
|
+
const deleteBooking = useAgendaStore((s) => s.deleteBooking)
|
|
69
|
+
const [confirming, setConfirming] = useState(false)
|
|
70
|
+
const [deleting, setDeleting] = useState(false)
|
|
71
|
+
|
|
72
|
+
async function handleDelete() {
|
|
73
|
+
setDeleting(true)
|
|
74
|
+
try { await deleteBooking(booking.id); onClose() } catch { setDeleting(false) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<FloatingPanel position={position} width={230} height={180} offset={{ x: 4, y: 4 }} onClose={onClose} zIndex={110}>
|
|
79
|
+
{(panel) => (
|
|
80
|
+
<>
|
|
81
|
+
<div className="py-1">
|
|
82
|
+
<button onClick={() => panel.expandThen(() => onEdit(booking.id))}
|
|
83
|
+
className="flex w-full items-center gap-3 px-4 py-2 text-sm hover:bg-muted/50 transition-colors">
|
|
84
|
+
<Pencil className="h-4 w-4 text-muted-foreground" /> {t('agenda.contextMenu.edit')}
|
|
85
|
+
</button>
|
|
86
|
+
{confirming ? (
|
|
87
|
+
<div className="flex items-center justify-between px-4 py-2">
|
|
88
|
+
<span className="text-xs text-destructive font-medium">{t('agenda.appointment.deleteConfirm')}</span>
|
|
89
|
+
<div className="flex items-center gap-1.5">
|
|
90
|
+
<button onClick={() => setConfirming(false)}
|
|
91
|
+
className="rounded px-2 py-0.5 text-[11px] font-medium hover:bg-muted/50 transition-colors">{t('agenda.appointment.cancel')}</button>
|
|
92
|
+
<button onClick={handleDelete} disabled={deleting}
|
|
93
|
+
className="rounded bg-destructive border border-destructive px-2 py-0.5 text-[11px] font-medium text-destructive-foreground hover:bg-destructive/90 shadow-button-primary active:shadow-button-inset disabled:opacity-40 transition-colors">{deleting ? t('agenda.appointment.deleting') : t('agenda.appointment.confirmDelete')}</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
) : (
|
|
97
|
+
<button onClick={() => setConfirming(true)}
|
|
98
|
+
className="flex w-full items-center gap-3 px-4 py-2 text-sm hover:bg-muted/50 transition-colors text-destructive">
|
|
99
|
+
<Trash2 className="h-4 w-4" /> {t('agenda.contextMenu.delete')}
|
|
100
|
+
</button>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className="border-t px-4 py-3">
|
|
105
|
+
<p className="text-[11px] font-medium text-muted-foreground mb-2">{t('agenda.contextMenu.changeStatus')}</p>
|
|
106
|
+
<div className="flex items-center gap-1.5">
|
|
107
|
+
{config.statuses.map((s) => {
|
|
108
|
+
const available = isStatusAvailable(s, booking.startsAt)
|
|
109
|
+
return (
|
|
110
|
+
<StatusDot key={s.value} color={s.color} label={available ? s.label : `${s.label} ${t('agenda.contextMenu.notAvailable')}`}
|
|
111
|
+
isActive={booking.status === s.value}
|
|
112
|
+
disabled={!available}
|
|
113
|
+
onClick={() => { if (available) { updateStatus(booking.id, s.value); panel.dismiss() } }} />
|
|
114
|
+
)
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</>
|
|
119
|
+
)}
|
|
120
|
+
</FloatingPanel>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Inject animations once
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const STYLE_ID = 'saas-floating-panel'
|
|
9
|
+
if (typeof document !== 'undefined' && !document.getElementById(STYLE_ID)) {
|
|
10
|
+
const s = document.createElement('style')
|
|
11
|
+
s.id = STYLE_ID
|
|
12
|
+
s.textContent = `
|
|
13
|
+
@keyframes _fp-dismiss { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(.96) } }
|
|
14
|
+
@keyframes _fp-expand { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(1.08) } }
|
|
15
|
+
.saas-fp-dismissing { animation: _fp-dismiss 150ms ease-in forwards; pointer-events: none }
|
|
16
|
+
.saas-fp-expanding { animation: _fp-expand 200ms ease-in forwards; pointer-events: none }
|
|
17
|
+
`
|
|
18
|
+
document.head.appendChild(s)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
type Phase = 'visible' | 'dismissing' | 'expanding'
|
|
26
|
+
|
|
27
|
+
export interface FloatingPanelRef {
|
|
28
|
+
/** Dismiss with fade-out, then call onClose */
|
|
29
|
+
dismiss: () => void
|
|
30
|
+
/** Expand-out animation (e.g. popover → modal), then call callback */
|
|
31
|
+
expandThen: (callback: () => void) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface FloatingPanelProps {
|
|
35
|
+
position: { x: number; y: number }
|
|
36
|
+
width: number
|
|
37
|
+
height: number
|
|
38
|
+
/** Offset from cursor (default: { x: 0, y: 0 }) */
|
|
39
|
+
offset?: { x: number; y: number }
|
|
40
|
+
onClose: () => void
|
|
41
|
+
className?: string
|
|
42
|
+
children: (ref: FloatingPanelRef) => React.ReactNode
|
|
43
|
+
zIndex?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Component
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export function FloatingPanel({
|
|
51
|
+
position, width, height, offset, onClose, className, children, zIndex = 100,
|
|
52
|
+
}: FloatingPanelProps) {
|
|
53
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
54
|
+
const [phase, setPhase] = useState<Phase>('visible')
|
|
55
|
+
const phaseRef = useRef<Phase>('visible')
|
|
56
|
+
phaseRef.current = phase
|
|
57
|
+
const onCloseRef = useRef(onClose)
|
|
58
|
+
onCloseRef.current = onClose
|
|
59
|
+
const expandCallbackRef = useRef<(() => void) | null>(null)
|
|
60
|
+
|
|
61
|
+
const dismiss = useCallback(() => {
|
|
62
|
+
if (phaseRef.current !== 'visible') return
|
|
63
|
+
setPhase('dismissing')
|
|
64
|
+
setTimeout(() => onCloseRef.current(), 150)
|
|
65
|
+
}, [])
|
|
66
|
+
|
|
67
|
+
const expandThen = useCallback((callback: () => void) => {
|
|
68
|
+
if (phaseRef.current !== 'visible') return
|
|
69
|
+
expandCallbackRef.current = callback
|
|
70
|
+
setPhase('expanding')
|
|
71
|
+
setTimeout(() => {
|
|
72
|
+
callback()
|
|
73
|
+
onCloseRef.current()
|
|
74
|
+
}, 220)
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
// Outside click + Escape
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
function handleClick(e: MouseEvent) {
|
|
80
|
+
if (phaseRef.current !== 'visible') return
|
|
81
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
82
|
+
// Don't dismiss if clicking another calendar event — let eventClick handle it
|
|
83
|
+
const target = e.target as HTMLElement
|
|
84
|
+
if (target.closest('.fc-event')) return
|
|
85
|
+
dismiss()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function handleKey(e: KeyboardEvent) {
|
|
89
|
+
if (e.key === 'Escape') dismiss()
|
|
90
|
+
}
|
|
91
|
+
document.addEventListener('mousedown', handleClick)
|
|
92
|
+
document.addEventListener('keydown', handleKey)
|
|
93
|
+
return () => {
|
|
94
|
+
document.removeEventListener('mousedown', handleClick)
|
|
95
|
+
document.removeEventListener('keydown', handleKey)
|
|
96
|
+
}
|
|
97
|
+
}, [dismiss])
|
|
98
|
+
|
|
99
|
+
// Clamp position to viewport
|
|
100
|
+
const ox = offset?.x ?? 0
|
|
101
|
+
const oy = offset?.y ?? 0
|
|
102
|
+
let x = position.x + ox
|
|
103
|
+
let y = position.y + oy
|
|
104
|
+
if (x + width > window.innerWidth - 12) x = x - width - ox * 2
|
|
105
|
+
if (y + height > window.innerHeight - 12) y = y - height - oy * 2
|
|
106
|
+
if (x < 12) x = 12
|
|
107
|
+
if (y < 12) y = 12
|
|
108
|
+
|
|
109
|
+
const panelRef: FloatingPanelRef = { dismiss, expandThen }
|
|
110
|
+
|
|
111
|
+
return createPortal(
|
|
112
|
+
<div ref={ref}
|
|
113
|
+
style={{ position: 'fixed', left: x, top: y, zIndex, transition: phase === 'visible' ? 'left 200ms ease-out, top 200ms ease-out' : undefined }}
|
|
114
|
+
className={`fayz-glass-surface rounded-modal border bg-popover shadow-lg overflow-hidden ${
|
|
115
|
+
phase === 'expanding' ? 'saas-fp-expanding'
|
|
116
|
+
: phase === 'dismissing' ? 'saas-fp-dismissing'
|
|
117
|
+
: 'animate-in fade-in zoom-in-95 duration-150'
|
|
118
|
+
} ${className ?? ''}`}>
|
|
119
|
+
{children(panelRef)}
|
|
120
|
+
</div>,
|
|
121
|
+
document.body,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
3
|
+
import { getCurrentLocale } from '@fayz-ai/core'
|
|
4
|
+
|
|
5
|
+
interface MiniCalendarProps {
|
|
6
|
+
selectedDate: Date
|
|
7
|
+
onDateSelect: (date: Date) => void
|
|
8
|
+
onMonthChange?: (date: Date) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DAY_LABELS: Record<string, string[]> = {
|
|
12
|
+
en: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],
|
|
13
|
+
'pt-BR': ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isSameDay(a: Date, b: Date) {
|
|
17
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isToday(d: Date) {
|
|
21
|
+
return isSameDay(d, new Date())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toDateLocale(locale: string): string {
|
|
25
|
+
if (locale === 'pt-BR') return 'pt-BR'
|
|
26
|
+
return 'en-US'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function MiniCalendar({ selectedDate, onDateSelect, onMonthChange }: MiniCalendarProps) {
|
|
30
|
+
const locale = getCurrentLocale()
|
|
31
|
+
const [viewMonth, setViewMonth] = React.useState(() => new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1))
|
|
32
|
+
|
|
33
|
+
const monthLabel = viewMonth.toLocaleDateString(toDateLocale(locale), { month: 'long', year: 'numeric' })
|
|
34
|
+
const dayLabels = DAY_LABELS[locale] ?? DAY_LABELS.en
|
|
35
|
+
|
|
36
|
+
const weeks = useMemo(() => {
|
|
37
|
+
const year = viewMonth.getFullYear()
|
|
38
|
+
const month = viewMonth.getMonth()
|
|
39
|
+
const firstDay = new Date(year, month, 1)
|
|
40
|
+
const startOffset = firstDay.getDay()
|
|
41
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
|
42
|
+
|
|
43
|
+
const cells: (Date | null)[] = []
|
|
44
|
+
for (let i = 0; i < startOffset; i++) cells.push(null)
|
|
45
|
+
for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d))
|
|
46
|
+
while (cells.length % 7 !== 0) cells.push(null)
|
|
47
|
+
|
|
48
|
+
const rows: (Date | null)[][] = []
|
|
49
|
+
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7))
|
|
50
|
+
return rows
|
|
51
|
+
}, [viewMonth])
|
|
52
|
+
|
|
53
|
+
function prevMonth() {
|
|
54
|
+
const d = new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1)
|
|
55
|
+
setViewMonth(d)
|
|
56
|
+
onMonthChange?.(d)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function nextMonth() {
|
|
60
|
+
const d = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1)
|
|
61
|
+
setViewMonth(d)
|
|
62
|
+
onMonthChange?.(d)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function handleClick(date: Date) {
|
|
66
|
+
onDateSelect(date)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="select-none">
|
|
71
|
+
{/* Month header */}
|
|
72
|
+
<div className="flex items-center justify-between mb-2">
|
|
73
|
+
<span className="text-sm font-medium capitalize">{monthLabel}</span>
|
|
74
|
+
<div className="flex items-center gap-0.5">
|
|
75
|
+
<button onClick={prevMonth} className="flex h-6 w-6 items-center justify-center rounded-full hover:bg-muted transition-colors">
|
|
76
|
+
<ChevronLeft className="h-3.5 w-3.5" />
|
|
77
|
+
</button>
|
|
78
|
+
<button onClick={nextMonth} className="flex h-6 w-6 items-center justify-center rounded-full hover:bg-muted transition-colors">
|
|
79
|
+
<ChevronRight className="h-3.5 w-3.5" />
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Day labels */}
|
|
85
|
+
<div className="grid grid-cols-7 mb-0.5">
|
|
86
|
+
{dayLabels.map((label, i) => (
|
|
87
|
+
<div key={i} className="flex h-6 w-6 items-center justify-center text-[10px] font-medium text-muted-foreground mx-auto">
|
|
88
|
+
{label}
|
|
89
|
+
</div>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Dates */}
|
|
94
|
+
{weeks.map((week, wi) => (
|
|
95
|
+
<div key={wi} className="grid grid-cols-7">
|
|
96
|
+
{week.map((date, di) => {
|
|
97
|
+
if (!date) return <div key={di} className="h-6 w-6 mx-auto" />
|
|
98
|
+
const selected = isSameDay(date, selectedDate)
|
|
99
|
+
const today = isToday(date)
|
|
100
|
+
return (
|
|
101
|
+
<button
|
|
102
|
+
key={di}
|
|
103
|
+
onClick={() => handleClick(date)}
|
|
104
|
+
className={`flex h-6 w-6 items-center justify-center rounded-full text-[11px] mx-auto transition-colors ${
|
|
105
|
+
selected
|
|
106
|
+
? 'bg-primary text-primary-foreground'
|
|
107
|
+
: today
|
|
108
|
+
? 'bg-primary/10 text-primary font-semibold'
|
|
109
|
+
: 'hover:bg-muted text-foreground'
|
|
110
|
+
}`}
|
|
111
|
+
>
|
|
112
|
+
{date.getDate()}
|
|
113
|
+
</button>
|
|
114
|
+
)
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { DollarSign, Check, AlertTriangle, Clock } from 'lucide-react'
|
|
3
|
+
import type { BookingPaymentStatus } from '../financial-bridge'
|
|
4
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
+
|
|
6
|
+
const STATUS_CONFIG: Record<BookingPaymentStatus, {
|
|
7
|
+
dot: string
|
|
8
|
+
text: string
|
|
9
|
+
bg: string
|
|
10
|
+
icon: React.ElementType
|
|
11
|
+
}> = {
|
|
12
|
+
none: { dot: '', text: 'text-muted-foreground', bg: 'hover:bg-muted/50', icon: DollarSign },
|
|
13
|
+
pending: { dot: 'bg-warning', text: 'text-warning', bg: 'bg-warning/10', icon: Clock },
|
|
14
|
+
partial: { dot: 'bg-warning', text: 'text-warning', bg: 'bg-warning/10', icon: Clock },
|
|
15
|
+
paid: { dot: 'bg-success', text: 'text-success', bg: 'bg-success/10', icon: Check },
|
|
16
|
+
overdue: { dot: 'bg-destructive', text: 'text-destructive', bg: 'bg-destructive/10', icon: AlertTriangle },
|
|
17
|
+
cancelled: { dot: 'bg-muted-foreground', text: 'text-muted-foreground', bg: 'bg-muted/30', icon: DollarSign },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PaymentStatusBadgeProps {
|
|
21
|
+
status: BookingPaymentStatus
|
|
22
|
+
totalAmount?: number
|
|
23
|
+
paidAmount?: number
|
|
24
|
+
formatCurrency?: (amount: number) => string
|
|
25
|
+
onClick?: () => void
|
|
26
|
+
compact?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function PaymentStatusBadge({
|
|
30
|
+
status, totalAmount, paidAmount, formatCurrency, onClick, compact,
|
|
31
|
+
}: PaymentStatusBadgeProps) {
|
|
32
|
+
const t = useTranslation()
|
|
33
|
+
const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.none
|
|
34
|
+
|
|
35
|
+
const label = status === 'none'
|
|
36
|
+
? t('agenda.payment.title')
|
|
37
|
+
: t(`agenda.payment.${status}`)
|
|
38
|
+
|
|
39
|
+
const amount = formatCurrency && totalAmount != null
|
|
40
|
+
? status === 'partial' && paidAmount != null
|
|
41
|
+
? `${formatCurrency(paidAmount)}/${formatCurrency(totalAmount)}`
|
|
42
|
+
: formatCurrency(totalAmount)
|
|
43
|
+
: null
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={onClick}
|
|
49
|
+
className={`inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors ${cfg.bg} ${cfg.text} ${
|
|
50
|
+
onClick ? 'cursor-pointer hover:opacity-80' : 'cursor-default'
|
|
51
|
+
}`}
|
|
52
|
+
>
|
|
53
|
+
{status === 'none' ? (
|
|
54
|
+
<DollarSign className="h-3 w-3 shrink-0" />
|
|
55
|
+
) : (
|
|
56
|
+
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${cfg.dot}`} />
|
|
57
|
+
)}
|
|
58
|
+
{!compact && <span>{label}</span>}
|
|
59
|
+
{amount && <span className="font-normal opacity-75">{amount}</span>}
|
|
60
|
+
</button>
|
|
61
|
+
)
|
|
62
|
+
}
|