@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.
Files changed (114) hide show
  1. package/README.md +51 -0
  2. package/dist/AgendaContext.d.ts +13 -0
  3. package/dist/AgendaContext.d.ts.map +1 -0
  4. package/dist/AgendaPage.d.ts +11 -0
  5. package/dist/AgendaPage.d.ts.map +1 -0
  6. package/dist/bridges/create-financial-bridge.d.ts +19 -0
  7. package/dist/bridges/create-financial-bridge.d.ts.map +1 -0
  8. package/dist/calendar-styles.d.ts +8 -0
  9. package/dist/calendar-styles.d.ts.map +1 -0
  10. package/dist/chunk-3LJKZQK4.js +12 -0
  11. package/dist/chunk-3LJKZQK4.js.map +1 -0
  12. package/dist/chunk-SYDBDI3Q.cjs +15 -0
  13. package/dist/chunk-SYDBDI3Q.cjs.map +1 -0
  14. package/dist/components/AgendaGeneralSettings.d.ts +3 -0
  15. package/dist/components/AgendaGeneralSettings.d.ts.map +1 -0
  16. package/dist/components/AppointmentModal.d.ts +16 -0
  17. package/dist/components/AppointmentModal.d.ts.map +1 -0
  18. package/dist/components/AppointmentPopover.d.ts +14 -0
  19. package/dist/components/AppointmentPopover.d.ts.map +1 -0
  20. package/dist/components/BookingPaymentPanel.d.ts +18 -0
  21. package/dist/components/BookingPaymentPanel.d.ts.map +1 -0
  22. package/dist/components/CalendarSidebar.d.ts +3 -0
  23. package/dist/components/CalendarSidebar.d.ts.map +1 -0
  24. package/dist/components/CalendarToolbar.d.ts +13 -0
  25. package/dist/components/CalendarToolbar.d.ts.map +1 -0
  26. package/dist/components/EventContextMenu.d.ts +14 -0
  27. package/dist/components/EventContextMenu.d.ts.map +1 -0
  28. package/dist/components/FloatingPanel.d.ts +27 -0
  29. package/dist/components/FloatingPanel.d.ts.map +1 -0
  30. package/dist/components/MiniCalendar.d.ts +9 -0
  31. package/dist/components/MiniCalendar.d.ts.map +1 -0
  32. package/dist/components/PaymentStatusBadge.d.ts +13 -0
  33. package/dist/components/PaymentStatusBadge.d.ts.map +1 -0
  34. package/dist/components/SearchCombobox.d.ts +38 -0
  35. package/dist/components/SearchCombobox.d.ts.map +1 -0
  36. package/dist/components/StatusBadge.d.ts +5 -0
  37. package/dist/components/StatusBadge.d.ts.map +1 -0
  38. package/dist/config.d.ts +151 -0
  39. package/dist/config.d.ts.map +1 -0
  40. package/dist/data/index.d.ts +4 -0
  41. package/dist/data/index.d.ts.map +1 -0
  42. package/dist/data/mock.d.ts +3 -0
  43. package/dist/data/mock.d.ts.map +1 -0
  44. package/dist/data/supabase.d.ts +3 -0
  45. package/dist/data/supabase.d.ts.map +1 -0
  46. package/dist/data/types.d.ts +26 -0
  47. package/dist/data/types.d.ts.map +1 -0
  48. package/dist/financial-bridge.d.ts +92 -0
  49. package/dist/financial-bridge.d.ts.map +1 -0
  50. package/dist/hooks/useAgendaSettings.d.ts +7 -0
  51. package/dist/hooks/useAgendaSettings.d.ts.map +1 -0
  52. package/dist/index.cjs +5135 -0
  53. package/dist/index.cjs.map +1 -0
  54. package/dist/index.d.ts +9 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +5120 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/lib/tenant.d.ts +3 -0
  59. package/dist/lib/tenant.d.ts.map +1 -0
  60. package/dist/locales/en.d.ts +2 -0
  61. package/dist/locales/en.d.ts.map +1 -0
  62. package/dist/locales/index.d.ts +2 -0
  63. package/dist/locales/index.d.ts.map +1 -0
  64. package/dist/locales/pt-BR.d.ts +2 -0
  65. package/dist/locales/pt-BR.d.ts.map +1 -0
  66. package/dist/registries.d.ts +3 -0
  67. package/dist/registries.d.ts.map +1 -0
  68. package/dist/store.d.ts +66 -0
  69. package/dist/store.d.ts.map +1 -0
  70. package/dist/tenant-222EHYUC.cjs +16 -0
  71. package/dist/tenant-222EHYUC.cjs.map +1 -0
  72. package/dist/tenant-LAUWVUYB.js +3 -0
  73. package/dist/tenant-LAUWVUYB.js.map +1 -0
  74. package/dist/types.d.ts +202 -0
  75. package/dist/types.d.ts.map +1 -0
  76. package/dist/views/CalendarView.d.ts +3 -0
  77. package/dist/views/CalendarView.d.ts.map +1 -0
  78. package/dist/views/ConfirmationsView.d.ts +3 -0
  79. package/dist/views/ConfirmationsView.d.ts.map +1 -0
  80. package/package.json +63 -0
  81. package/src/AgendaContext.tsx +11 -0
  82. package/src/AgendaPage.tsx +33 -0
  83. package/src/bridges/create-financial-bridge.ts +298 -0
  84. package/src/calendar-styles.css +245 -0
  85. package/src/calendar-styles.ts +285 -0
  86. package/src/components/AgendaGeneralSettings.tsx +64 -0
  87. package/src/components/AppointmentModal.tsx +1031 -0
  88. package/src/components/AppointmentPopover.tsx +221 -0
  89. package/src/components/BookingPaymentPanel.tsx +394 -0
  90. package/src/components/CalendarSidebar.tsx +119 -0
  91. package/src/components/CalendarToolbar.tsx +83 -0
  92. package/src/components/EventContextMenu.tsx +122 -0
  93. package/src/components/FloatingPanel.tsx +123 -0
  94. package/src/components/MiniCalendar.tsx +120 -0
  95. package/src/components/PaymentStatusBadge.tsx +62 -0
  96. package/src/components/SearchCombobox.tsx +216 -0
  97. package/src/components/StatusBadge.tsx +23 -0
  98. package/src/config.ts +269 -0
  99. package/src/data/index.ts +3 -0
  100. package/src/data/mock.ts +324 -0
  101. package/src/data/supabase.ts +570 -0
  102. package/src/data/types.ts +37 -0
  103. package/src/financial-bridge.ts +96 -0
  104. package/src/hooks/useAgendaSettings.ts +17 -0
  105. package/src/index.ts +180 -0
  106. package/src/lib/tenant.ts +3 -0
  107. package/src/locales/en.ts +207 -0
  108. package/src/locales/index.ts +7 -0
  109. package/src/locales/pt-BR.ts +207 -0
  110. package/src/registries.ts +36 -0
  111. package/src/store.ts +304 -0
  112. package/src/types.ts +265 -0
  113. package/src/views/CalendarView.tsx +904 -0
  114. 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
+ }