@fayz-ai/ui 0.9.0 → 0.10.1
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 +2 -2
- package/dist/analytics/AnalyticsCard.d.ts +10 -0
- package/dist/analytics/AnalyticsCard.d.ts.map +1 -0
- package/dist/analytics/AnalyticsControls.d.ts +18 -0
- package/dist/analytics/AnalyticsControls.d.ts.map +1 -0
- package/dist/analytics/AnalyticsGrid.d.ts +16 -0
- package/dist/analytics/AnalyticsGrid.d.ts.map +1 -0
- package/dist/analytics/context.d.ts +58 -0
- package/dist/analytics/context.d.ts.map +1 -0
- package/dist/analytics/derive.d.ts +81 -0
- package/dist/analytics/derive.d.ts.map +1 -0
- package/dist/analytics/index.d.ts +13 -0
- package/dist/analytics/index.d.ts.map +1 -0
- package/dist/analytics/range.d.ts +22 -0
- package/dist/analytics/range.d.ts.map +1 -0
- package/dist/{chunk-PVIB724L.js → chunk-22EVFMS3.js} +5 -3
- package/dist/chunk-22EVFMS3.js.map +1 -0
- package/dist/{chunk-G6OKZ7WL.js → chunk-6V5MGYXH.js} +13 -9
- package/dist/chunk-6V5MGYXH.js.map +1 -0
- package/dist/{chunk-KV3MHB6N.js → chunk-H63TOSQ7.js} +196 -53
- package/dist/chunk-H63TOSQ7.js.map +1 -0
- package/dist/{chunk-OUPJDGKE.js → chunk-I6CPKAOD.js} +134 -37
- package/dist/chunk-I6CPKAOD.js.map +1 -0
- package/dist/{chunk-YRVE4FOD.js → chunk-OKZ6WQB3.js} +3 -3
- package/dist/chunk-OKZ6WQB3.js.map +1 -0
- package/dist/{chunk-S5KNM4D2.js → chunk-SEIY2F5F.js} +235 -107
- package/dist/chunk-SEIY2F5F.js.map +1 -0
- package/dist/{chunk-75EFDLST.js → chunk-VAKWUUVC.js} +157 -6
- package/dist/chunk-VAKWUUVC.js.map +1 -0
- package/dist/dashboard/OnboardingWidget.d.ts +2 -14
- package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
- package/dist/dashboard/SidebarOnboarding.d.ts +28 -0
- package/dist/dashboard/SidebarOnboarding.d.ts.map +1 -0
- package/dist/dashboard/index.d.ts +2 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +4 -4
- package/dist/dashboard/types.d.ts +18 -0
- package/dist/dashboard/types.d.ts.map +1 -1
- package/dist/dashboard/useOnboardingProgress.d.ts +28 -0
- package/dist/dashboard/useOnboardingProgress.d.ts.map +1 -0
- package/dist/icons.js +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +960 -14
- package/dist/index.js.map +1 -1
- package/dist/layout/AppShell.d.ts +14 -1
- package/dist/layout/AppShell.d.ts.map +1 -1
- package/dist/layout/ModulePage.d.ts +11 -2
- package/dist/layout/ModulePage.d.ts.map +1 -1
- package/dist/layout/Sidebar.d.ts +8 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/index.js +4 -4
- package/dist/lib/cn.d.ts.map +1 -0
- package/dist/preset.d.ts +3 -0
- package/dist/preset.d.ts.map +1 -0
- package/dist/primitives/data-table.d.ts +19 -4
- package/dist/primitives/data-table.d.ts.map +1 -1
- package/dist/primitives/date-picker.d.ts +4 -2
- package/dist/primitives/date-picker.d.ts.map +1 -1
- package/dist/primitives/filter-chip.d.ts +26 -0
- package/dist/primitives/filter-chip.d.ts.map +1 -0
- package/dist/primitives/header-tabs.d.ts +32 -0
- package/dist/primitives/header-tabs.d.ts.map +1 -0
- package/dist/primitives/hint.d.ts +25 -0
- package/dist/primitives/hint.d.ts.map +1 -0
- package/dist/primitives/index.d.ts +7 -0
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +4 -4
- package/dist/primitives/list-view.d.ts +14 -1
- package/dist/primitives/list-view.d.ts.map +1 -1
- package/dist/primitives/page-header.d.ts +23 -0
- package/dist/primitives/page-header.d.ts.map +1 -0
- package/dist/primitives/stat-tiles.d.ts +30 -0
- package/dist/primitives/stat-tiles.d.ts.map +1 -0
- package/dist/primitives/switch.d.ts +11 -0
- package/dist/primitives/switch.d.ts.map +1 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.d.ts.map +1 -1
- package/dist/stores/page-header.store.d.ts +69 -0
- package/dist/stores/page-header.store.d.ts.map +1 -0
- package/package.json +6 -15
- package/dist/chunk-75EFDLST.js.map +0 -1
- package/dist/chunk-G6OKZ7WL.js.map +0 -1
- package/dist/chunk-KV3MHB6N.js.map +0 -1
- package/dist/chunk-OUPJDGKE.js.map +0 -1
- package/dist/chunk-PVIB724L.js.map +0 -1
- package/dist/chunk-S5KNM4D2.js.map +0 -1
- package/dist/chunk-YRVE4FOD.js.map +0 -1
- package/dist/utils/cn.d.ts.map +0 -1
- package/src/dashboard/ChartWidget.tsx +0 -50
- package/src/dashboard/ChartWidgetInner.tsx +0 -83
- package/src/dashboard/DashboardCanvas.tsx +0 -210
- package/src/dashboard/DashboardCustomizeMenu.tsx +0 -53
- package/src/dashboard/DashboardGrid.tsx +0 -35
- package/src/dashboard/DashboardRange.tsx +0 -62
- package/src/dashboard/GoldCard.tsx +0 -38
- package/src/dashboard/KpiCard.tsx +0 -238
- package/src/dashboard/OnboardingWidget.tsx +0 -140
- package/src/dashboard/TableWidget.tsx +0 -51
- package/src/dashboard/define.ts +0 -53
- package/src/dashboard/icon.ts +0 -13
- package/src/dashboard/index.ts +0 -38
- package/src/dashboard/layout.ts +0 -50
- package/src/dashboard/preferences.ts +0 -71
- package/src/dashboard/types.ts +0 -41
- package/src/icons.ts +0 -66
- package/src/index.ts +0 -238
- package/src/layout/AppShell.tsx +0 -987
- package/src/layout/ContentSplit.tsx +0 -153
- package/src/layout/ModulePage.tsx +0 -466
- package/src/layout/PageTransition.tsx +0 -57
- package/src/layout/SaveBar.tsx +0 -247
- package/src/layout/Sidebar.tsx +0 -535
- package/src/layout/Topbar.tsx +0 -462
- package/src/layout/index.ts +0 -39
- package/src/layout/useCrossModuleBack.ts +0 -30
- package/src/primitives/Avatar.tsx +0 -48
- package/src/primitives/SearchCombobox.tsx +0 -216
- package/src/primitives/badge.tsx +0 -68
- package/src/primitives/breadcrumb.tsx +0 -43
- package/src/primitives/button.tsx +0 -60
- package/src/primitives/card.tsx +0 -79
- package/src/primitives/checkbox.tsx +0 -53
- package/src/primitives/confirm-dialog.tsx +0 -125
- package/src/primitives/currency-input.tsx +0 -121
- package/src/primitives/data-table.tsx +0 -160
- package/src/primitives/date-picker.tsx +0 -225
- package/src/primitives/dropdown.tsx +0 -196
- package/src/primitives/index.ts +0 -87
- package/src/primitives/input.tsx +0 -50
- package/src/primitives/list-view.tsx +0 -252
- package/src/primitives/markdown-editor.tsx +0 -75
- package/src/primitives/markdown.tsx +0 -169
- package/src/primitives/modal.tsx +0 -156
- package/src/primitives/phone-link.tsx +0 -138
- package/src/primitives/popover.tsx +0 -30
- package/src/primitives/search-select.tsx +0 -314
- package/src/primitives/segmented-control.tsx +0 -66
- package/src/primitives/select.tsx +0 -157
- package/src/primitives/separator.tsx +0 -29
- package/src/primitives/sheet.tsx +0 -181
- package/src/primitives/skeleton.tsx +0 -46
- package/src/primitives/tabs.tsx +0 -53
- package/src/primitives/time-picker.tsx +0 -148
- package/src/primitives/toast.tsx +0 -46
- package/src/primitives/tooltip.tsx +0 -28
- package/src/stores/index.ts +0 -10
- package/src/stores/layout.store.ts +0 -31
- package/src/stores/theme.store.ts +0 -457
- package/src/tailwind.ts +0 -43
- package/src/theme/index.ts +0 -94
- package/src/theme/preset.ts +0 -209
- package/src/theme/presets.ts +0 -106
- package/src/utils/cn.ts +0 -6
- /package/dist/{utils → lib}/cn.d.ts +0 -0
- /package/{src → dist}/styles.css +0 -0
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import { cn } from '../utils/cn'
|
|
3
|
-
|
|
4
|
-
interface CurrencyInputProps {
|
|
5
|
-
value: number
|
|
6
|
-
onChange: (value: number) => void
|
|
7
|
-
symbol?: string
|
|
8
|
-
locale?: string
|
|
9
|
-
currencyCode?: string
|
|
10
|
-
label?: string
|
|
11
|
-
placeholder?: string
|
|
12
|
-
className?: string
|
|
13
|
-
disabled?: boolean
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Currency input with automatic decimal formatting.
|
|
18
|
-
*
|
|
19
|
-
* - Always displays the value with 2 decimal places using the project's locale
|
|
20
|
-
* - Input works like an ATM: digits push from right, decimals are fixed
|
|
21
|
-
* - Typing "12345" shows "123,45" (or "123.45" depending on locale)
|
|
22
|
-
* - Backspace removes the last digit
|
|
23
|
-
* - Supports the project currency symbol
|
|
24
|
-
*/
|
|
25
|
-
export function CurrencyInput({
|
|
26
|
-
value,
|
|
27
|
-
onChange,
|
|
28
|
-
symbol = 'R$',
|
|
29
|
-
locale = 'pt-BR',
|
|
30
|
-
currencyCode = 'BRL',
|
|
31
|
-
label,
|
|
32
|
-
placeholder,
|
|
33
|
-
className,
|
|
34
|
-
disabled,
|
|
35
|
-
}: CurrencyInputProps) {
|
|
36
|
-
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
37
|
-
const [focused, setFocused] = React.useState(false)
|
|
38
|
-
|
|
39
|
-
// Format number to locale string with 2 decimals
|
|
40
|
-
const format = React.useCallback((v: number) => {
|
|
41
|
-
return new Intl.NumberFormat(locale, {
|
|
42
|
-
minimumFractionDigits: 2,
|
|
43
|
-
maximumFractionDigits: 2,
|
|
44
|
-
}).format(v)
|
|
45
|
-
}, [locale])
|
|
46
|
-
|
|
47
|
-
const displayValue = format(value)
|
|
48
|
-
|
|
49
|
-
// Handle key-by-key input — ATM-style (digits push from right)
|
|
50
|
-
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
51
|
-
if (disabled) return
|
|
52
|
-
|
|
53
|
-
// Allow tab, arrow keys, etc.
|
|
54
|
-
if (['Tab', 'Escape', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return
|
|
55
|
-
|
|
56
|
-
e.preventDefault()
|
|
57
|
-
|
|
58
|
-
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
59
|
-
// Remove last digit: 123.45 → 12.34
|
|
60
|
-
const cents = Math.floor(Math.round(value * 100) / 10)
|
|
61
|
-
onChange(cents / 100)
|
|
62
|
-
return
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Only accept digits
|
|
66
|
-
if (!/^\d$/.test(e.key)) return
|
|
67
|
-
|
|
68
|
-
// Append digit: 12.34 + '5' → 123.45
|
|
69
|
-
const currentCents = Math.round(value * 100)
|
|
70
|
-
const newCents = currentCents * 10 + parseInt(e.key, 10)
|
|
71
|
-
// Cap at 999,999,999.99
|
|
72
|
-
if (newCents > 99999999999) return
|
|
73
|
-
onChange(newCents / 100)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Handle paste — parse number from pasted text
|
|
77
|
-
function handlePaste(e: React.ClipboardEvent) {
|
|
78
|
-
e.preventDefault()
|
|
79
|
-
const text = e.clipboardData.getData('text')
|
|
80
|
-
// Remove everything except digits, dots, commas
|
|
81
|
-
const cleaned = text.replace(/[^\d.,]/g, '')
|
|
82
|
-
// Replace comma with dot for parsing
|
|
83
|
-
const normalized = cleaned.replace(',', '.')
|
|
84
|
-
const parsed = parseFloat(normalized)
|
|
85
|
-
if (!isNaN(parsed)) {
|
|
86
|
-
onChange(Math.round(parsed * 100) / 100)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return (
|
|
91
|
-
<div className={className}>
|
|
92
|
-
{label && <label className="text-xs font-medium text-muted-foreground">{label}</label>}
|
|
93
|
-
<div
|
|
94
|
-
className={cn(
|
|
95
|
-
'flex items-center rounded-lg border border-input bg-background ring-offset-background transition-colors cursor-text',
|
|
96
|
-
label && 'mt-1',
|
|
97
|
-
focused && 'border-ring ring-2 ring-ring ring-offset-2',
|
|
98
|
-
disabled && 'opacity-50 cursor-not-allowed',
|
|
99
|
-
)}
|
|
100
|
-
onClick={() => inputRef.current?.focus()}
|
|
101
|
-
>
|
|
102
|
-
<span className="pl-3 pr-1 text-sm font-medium text-muted-foreground select-none">{symbol}</span>
|
|
103
|
-
<input
|
|
104
|
-
ref={inputRef}
|
|
105
|
-
type="text"
|
|
106
|
-
inputMode="numeric"
|
|
107
|
-
value={displayValue}
|
|
108
|
-
onKeyDown={handleKeyDown}
|
|
109
|
-
onPaste={handlePaste}
|
|
110
|
-
onFocus={() => setFocused(true)}
|
|
111
|
-
onBlur={() => setFocused(false)}
|
|
112
|
-
placeholder={placeholder ?? format(0)}
|
|
113
|
-
disabled={disabled}
|
|
114
|
-
readOnly={false}
|
|
115
|
-
className="flex-1 bg-transparent px-2 py-2 text-sm outline-none text-right tabular-nums caret-primary selection:bg-primary/20"
|
|
116
|
-
onChange={() => {}}
|
|
117
|
-
/>
|
|
118
|
-
</div>
|
|
119
|
-
</div>
|
|
120
|
-
)
|
|
121
|
-
}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import * as React from 'react'
|
|
2
|
-
import {
|
|
3
|
-
type ColumnDef,
|
|
4
|
-
type SortingState,
|
|
5
|
-
flexRender,
|
|
6
|
-
getCoreRowModel,
|
|
7
|
-
getSortedRowModel,
|
|
8
|
-
useReactTable,
|
|
9
|
-
} from '@tanstack/react-table'
|
|
10
|
-
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
|
11
|
-
|
|
12
|
-
import { cn } from '../utils/cn'
|
|
13
|
-
|
|
14
|
-
interface DataTableProps<TData, TValue> {
|
|
15
|
-
columns: ColumnDef<TData, TValue>[]
|
|
16
|
-
data: TData[]
|
|
17
|
-
loading?: boolean
|
|
18
|
-
skeletonRows?: number
|
|
19
|
-
emptyMessage?: string
|
|
20
|
-
onRowClick?: (row: TData) => void
|
|
21
|
-
/** Visual variant: 'card' (white bg, card wrapper — default) or 'flat' (transparent bg) */
|
|
22
|
-
variant?: 'card' | 'flat'
|
|
23
|
-
/** Compact mode: smaller padding and font size */
|
|
24
|
-
compact?: boolean
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function DataTable<TData, TValue>({
|
|
28
|
-
columns,
|
|
29
|
-
data,
|
|
30
|
-
loading = false,
|
|
31
|
-
skeletonRows = 3,
|
|
32
|
-
emptyMessage = 'No results.',
|
|
33
|
-
onRowClick,
|
|
34
|
-
variant = 'card',
|
|
35
|
-
compact = false,
|
|
36
|
-
}: DataTableProps<TData, TValue>) {
|
|
37
|
-
const isCard = variant === 'card'
|
|
38
|
-
// Rounded box and scroll container must be SEPARATE elements: a scroll
|
|
39
|
-
// container paints its content over its own border-radius, which squared
|
|
40
|
-
// the corners the moment a row background met them.
|
|
41
|
-
const wrapperCls = isCard
|
|
42
|
-
? 'overflow-hidden rounded-lg border bg-card shadow-sm'
|
|
43
|
-
: 'overflow-hidden rounded-lg border'
|
|
44
|
-
const headerCls = isCard ? 'border-b bg-muted/50' : 'border-b bg-muted/30'
|
|
45
|
-
const thCls = compact ? 'px-3 py-1.5 text-left text-[11px] font-medium text-muted-foreground' : 'px-4 py-2.5 text-left font-medium text-muted-foreground'
|
|
46
|
-
const tdCls = compact ? 'px-3 py-1.5 align-middle text-xs' : 'px-4 py-3 align-middle'
|
|
47
|
-
const tableCls = compact ? 'w-full text-xs min-w-max' : 'w-full text-sm min-w-max'
|
|
48
|
-
const rowHoverCls = isCard ? 'hover:bg-muted/30' : 'hover:bg-muted/20'
|
|
49
|
-
const [sorting, setSorting] = React.useState<SortingState>([])
|
|
50
|
-
|
|
51
|
-
const table = useReactTable({
|
|
52
|
-
data,
|
|
53
|
-
columns,
|
|
54
|
-
getCoreRowModel: getCoreRowModel(),
|
|
55
|
-
getSortedRowModel: getSortedRowModel(),
|
|
56
|
-
onSortingChange: setSorting,
|
|
57
|
-
state: { sorting },
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
if (loading) {
|
|
61
|
-
return (
|
|
62
|
-
<div className={wrapperCls}>
|
|
63
|
-
<div className="overflow-x-auto">
|
|
64
|
-
<table className={tableCls}>
|
|
65
|
-
<thead>
|
|
66
|
-
<tr className={headerCls}>
|
|
67
|
-
{columns.map((_, i) => (
|
|
68
|
-
<th key={i} className={thCls}>
|
|
69
|
-
<div className="h-3.5 w-20 animate-pulse rounded bg-muted" />
|
|
70
|
-
</th>
|
|
71
|
-
))}
|
|
72
|
-
</tr>
|
|
73
|
-
</thead>
|
|
74
|
-
<tbody>
|
|
75
|
-
{Array.from({ length: skeletonRows }).map((_, i) => (
|
|
76
|
-
<tr key={i} className="border-b last:border-0">
|
|
77
|
-
{columns.map((_, j) => (
|
|
78
|
-
<td key={j} className={tdCls}>
|
|
79
|
-
<div className="h-3.5 w-full animate-pulse rounded bg-muted" />
|
|
80
|
-
</td>
|
|
81
|
-
))}
|
|
82
|
-
</tr>
|
|
83
|
-
))}
|
|
84
|
-
</tbody>
|
|
85
|
-
</table>
|
|
86
|
-
</div>
|
|
87
|
-
</div>
|
|
88
|
-
)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return (
|
|
92
|
-
<div className={wrapperCls}>
|
|
93
|
-
<div className="overflow-x-auto">
|
|
94
|
-
<table className={tableCls}>
|
|
95
|
-
<thead>
|
|
96
|
-
{table.getHeaderGroups().map((headerGroup) => (
|
|
97
|
-
<tr key={headerGroup.id} className={headerCls}>
|
|
98
|
-
{headerGroup.headers.map((header) => {
|
|
99
|
-
const sorted = header.column.getIsSorted()
|
|
100
|
-
return (
|
|
101
|
-
<th
|
|
102
|
-
key={header.id}
|
|
103
|
-
className={thCls}
|
|
104
|
-
>
|
|
105
|
-
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
|
106
|
-
<button
|
|
107
|
-
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
|
108
|
-
onClick={header.column.getToggleSortingHandler()}
|
|
109
|
-
>
|
|
110
|
-
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
111
|
-
{sorted === 'asc' ? (
|
|
112
|
-
<ArrowUp className="h-3 w-3" />
|
|
113
|
-
) : sorted === 'desc' ? (
|
|
114
|
-
<ArrowDown className="h-3 w-3" />
|
|
115
|
-
) : (
|
|
116
|
-
<ArrowUpDown className="h-3 w-3 opacity-40" />
|
|
117
|
-
)}
|
|
118
|
-
</button>
|
|
119
|
-
) : (
|
|
120
|
-
flexRender(header.column.columnDef.header, header.getContext())
|
|
121
|
-
)}
|
|
122
|
-
</th>
|
|
123
|
-
)
|
|
124
|
-
})}
|
|
125
|
-
</tr>
|
|
126
|
-
))}
|
|
127
|
-
</thead>
|
|
128
|
-
<tbody>
|
|
129
|
-
{table.getRowModel().rows?.length ? (
|
|
130
|
-
table.getRowModel().rows.map((row) => (
|
|
131
|
-
<tr
|
|
132
|
-
key={row.id}
|
|
133
|
-
className={cn(
|
|
134
|
-
`border-b last:border-0 transition-colors ${rowHoverCls}`,
|
|
135
|
-
onRowClick && 'cursor-pointer'
|
|
136
|
-
)}
|
|
137
|
-
onClick={() => onRowClick?.(row.original)}
|
|
138
|
-
>
|
|
139
|
-
{row.getVisibleCells().map((cell) => (
|
|
140
|
-
<td key={cell.id} className={tdCls}>
|
|
141
|
-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
142
|
-
</td>
|
|
143
|
-
))}
|
|
144
|
-
</tr>
|
|
145
|
-
))
|
|
146
|
-
) : (
|
|
147
|
-
<tr>
|
|
148
|
-
<td colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
|
149
|
-
{emptyMessage}
|
|
150
|
-
</td>
|
|
151
|
-
</tr>
|
|
152
|
-
)}
|
|
153
|
-
</tbody>
|
|
154
|
-
</table>
|
|
155
|
-
</div>
|
|
156
|
-
</div>
|
|
157
|
-
)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export { DataTable, type DataTableProps }
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
|
-
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react'
|
|
4
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
-
import { getCurrentLocale } from '@fayz-ai/core'
|
|
6
|
-
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// DatePicker — custom dropdown calendar, Google Calendar style
|
|
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 toDateLocale(locale: string): string {
|
|
17
|
-
if (locale === 'pt-BR') return 'pt-BR'
|
|
18
|
-
return 'en-US'
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function isSameDay(a: Date, b: Date) {
|
|
22
|
-
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function isToday(d: Date) { return isSameDay(d, new Date()) }
|
|
26
|
-
|
|
27
|
-
function formatDate(date: Date, locale: string) {
|
|
28
|
-
return date.toLocaleDateString(toDateLocale(locale), { month: 'short', day: 'numeric', year: 'numeric' })
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Toolbar label: the year is noise while you're inside it, and two of these sit
|
|
32
|
-
* side by side in a range — it only earns its space on another year. */
|
|
33
|
-
function formatDateCompact(date: Date, locale: string) {
|
|
34
|
-
const sameYear = date.getFullYear() === new Date().getFullYear()
|
|
35
|
-
return date.toLocaleDateString(toDateLocale(locale), {
|
|
36
|
-
month: 'short',
|
|
37
|
-
day: 'numeric',
|
|
38
|
-
...(sameYear ? {} : { year: 'numeric' }),
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function toDateStr(d: Date) {
|
|
43
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function parseDate(s: string): Date {
|
|
47
|
-
const [y, m, d] = s.split('-').map(Number)
|
|
48
|
-
return new Date(y, m - 1, d)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface DatePickerProps {
|
|
52
|
-
/** Value as YYYY-MM-DD string */
|
|
53
|
-
value: string
|
|
54
|
-
onChange: (value: string) => void
|
|
55
|
-
className?: string
|
|
56
|
-
/**
|
|
57
|
-
* `input` (default) is the form-field trigger. `toolbar` is the 26px pill that
|
|
58
|
-
* matches SegmentedControl/Button `sm`, for filter bars where a full-height
|
|
59
|
-
* form field towers over everything beside it.
|
|
60
|
-
*/
|
|
61
|
-
variant?: 'input' | 'toolbar'
|
|
62
|
-
/** Open the dropdown immediately on mount */
|
|
63
|
-
defaultOpen?: boolean
|
|
64
|
-
/** Called when the dropdown opens or closes */
|
|
65
|
-
onOpenChange?: (open: boolean) => void
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const TRIGGER_VARIANT: Record<'input' | 'toolbar', string> = {
|
|
69
|
-
input: 'rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm w-full',
|
|
70
|
-
toolbar: 'h-[26px] rounded-button border border-border bg-card shadow-button px-2.5 text-xs font-medium',
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function DatePicker({ value, onChange, className, variant = 'input', defaultOpen, onOpenChange }: DatePickerProps) {
|
|
74
|
-
const t = useTranslation()
|
|
75
|
-
const locale = getCurrentLocale()
|
|
76
|
-
const [open, setOpen] = useState(defaultOpen ?? false)
|
|
77
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
78
|
-
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
79
|
-
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
80
|
-
const selectedDate = value ? parseDate(value) : new Date()
|
|
81
|
-
const [viewMonth, setViewMonth] = useState(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1))
|
|
82
|
-
const [pos, setPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
|
|
83
|
-
|
|
84
|
-
const dayLabels = DAY_LABELS[locale] ?? DAY_LABELS.en
|
|
85
|
-
const dateLocale = toDateLocale(locale)
|
|
86
|
-
|
|
87
|
-
// Position the portal dropdown relative to the trigger
|
|
88
|
-
useEffect(() => {
|
|
89
|
-
if (!open || !triggerRef.current) return
|
|
90
|
-
const rect = triggerRef.current.getBoundingClientRect()
|
|
91
|
-
const dropW = 260
|
|
92
|
-
let left = rect.left
|
|
93
|
-
if (left + dropW > window.innerWidth - 12) left = window.innerWidth - dropW - 12
|
|
94
|
-
if (left < 8) left = 8
|
|
95
|
-
setPos({ top: rect.bottom + 4, left })
|
|
96
|
-
}, [open])
|
|
97
|
-
|
|
98
|
-
useEffect(() => {
|
|
99
|
-
if (!open) return
|
|
100
|
-
const handler = (e: MouseEvent) => {
|
|
101
|
-
const target = e.target as Node
|
|
102
|
-
if (ref.current?.contains(target)) return
|
|
103
|
-
if (dropdownRef.current?.contains(target)) return
|
|
104
|
-
setOpen(false)
|
|
105
|
-
onOpenChange?.(false)
|
|
106
|
-
}
|
|
107
|
-
document.addEventListener('mousedown', handler)
|
|
108
|
-
return () => document.removeEventListener('mousedown', handler)
|
|
109
|
-
}, [open, onOpenChange])
|
|
110
|
-
|
|
111
|
-
// Sync view month when value changes externally
|
|
112
|
-
useEffect(() => {
|
|
113
|
-
if (value) {
|
|
114
|
-
const d = parseDate(value)
|
|
115
|
-
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1))
|
|
116
|
-
}
|
|
117
|
-
}, [value])
|
|
118
|
-
|
|
119
|
-
const monthLabel = viewMonth.toLocaleDateString(dateLocale, { month: 'long', year: 'numeric' })
|
|
120
|
-
|
|
121
|
-
const weeks = useMemo(() => {
|
|
122
|
-
const year = viewMonth.getFullYear()
|
|
123
|
-
const month = viewMonth.getMonth()
|
|
124
|
-
const firstDay = new Date(year, month, 1)
|
|
125
|
-
const startOffset = firstDay.getDay()
|
|
126
|
-
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
|
127
|
-
const prevMonthDays = new Date(year, month, 0).getDate()
|
|
128
|
-
|
|
129
|
-
const cells: { date: Date; inMonth: boolean }[] = []
|
|
130
|
-
for (let i = startOffset - 1; i >= 0; i--) {
|
|
131
|
-
cells.push({ date: new Date(year, month - 1, prevMonthDays - i), inMonth: false })
|
|
132
|
-
}
|
|
133
|
-
for (let d = 1; d <= daysInMonth; d++) {
|
|
134
|
-
cells.push({ date: new Date(year, month, d), inMonth: true })
|
|
135
|
-
}
|
|
136
|
-
while (cells.length % 7 !== 0) {
|
|
137
|
-
cells.push({ date: new Date(year, month + 1, cells.length - startOffset - daysInMonth + 1), inMonth: false })
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const rows: typeof cells[] = []
|
|
141
|
-
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7))
|
|
142
|
-
return rows
|
|
143
|
-
}, [viewMonth])
|
|
144
|
-
|
|
145
|
-
function handleSelect(date: Date) {
|
|
146
|
-
onChange(toDateStr(date))
|
|
147
|
-
setOpen(false)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return (
|
|
151
|
-
<div ref={ref} className={`relative ${className ?? ''}`}>
|
|
152
|
-
{/* Trigger */}
|
|
153
|
-
<button ref={triggerRef} type="button" onClick={() => setOpen((p) => !p)}
|
|
154
|
-
className={`flex items-center gap-2 text-left transition-colors hover:bg-muted/30 ${TRIGGER_VARIANT[variant]}`}>
|
|
155
|
-
<Calendar className={`shrink-0 text-muted-foreground ${variant === 'toolbar' ? 'h-3 w-3' : 'h-3.5 w-3.5'}`} />
|
|
156
|
-
<span className="whitespace-nowrap">
|
|
157
|
-
{value
|
|
158
|
-
? (variant === 'toolbar' ? formatDateCompact : formatDate)(selectedDate, locale)
|
|
159
|
-
: t('common.selectDate')}
|
|
160
|
-
</span>
|
|
161
|
-
</button>
|
|
162
|
-
|
|
163
|
-
{/* Dropdown — portal to escape overflow containers */}
|
|
164
|
-
{open && createPortal(
|
|
165
|
-
<div ref={dropdownRef} data-modal-passthrough
|
|
166
|
-
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, pointerEvents: 'auto' }}
|
|
167
|
-
className="fayz-glass-surface rounded-xl border bg-popover shadow-xl p-3 w-[260px]">
|
|
168
|
-
{/* Month nav */}
|
|
169
|
-
<div className="flex items-center justify-between mb-2">
|
|
170
|
-
<span className="text-sm font-medium capitalize">{monthLabel}</span>
|
|
171
|
-
<div className="flex items-center gap-0.5">
|
|
172
|
-
<button type="button" onClick={() => setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1))}
|
|
173
|
-
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors">
|
|
174
|
-
<ChevronLeft className="h-4 w-4" />
|
|
175
|
-
</button>
|
|
176
|
-
<button type="button" onClick={() => setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1))}
|
|
177
|
-
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted transition-colors">
|
|
178
|
-
<ChevronRight className="h-4 w-4" />
|
|
179
|
-
</button>
|
|
180
|
-
</div>
|
|
181
|
-
</div>
|
|
182
|
-
|
|
183
|
-
{/* Day labels */}
|
|
184
|
-
<div className="grid grid-cols-7 mb-1">
|
|
185
|
-
{dayLabels.map((l, i) => (
|
|
186
|
-
<div key={i} className="flex h-8 items-center justify-center text-[11px] font-medium text-muted-foreground">{l}</div>
|
|
187
|
-
))}
|
|
188
|
-
</div>
|
|
189
|
-
|
|
190
|
-
{/* Dates */}
|
|
191
|
-
{weeks.map((week, wi) => (
|
|
192
|
-
<div key={wi} className="grid grid-cols-7">
|
|
193
|
-
{week.map(({ date, inMonth }, di) => {
|
|
194
|
-
const sel = value && isSameDay(date, selectedDate)
|
|
195
|
-
const today = isToday(date)
|
|
196
|
-
return (
|
|
197
|
-
<button key={di} type="button"
|
|
198
|
-
onClick={() => handleSelect(date)}
|
|
199
|
-
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs mx-auto transition-colors ${
|
|
200
|
-
sel
|
|
201
|
-
? 'bg-primary text-primary-foreground font-semibold'
|
|
202
|
-
: today
|
|
203
|
-
? 'bg-primary/10 text-primary font-semibold'
|
|
204
|
-
: inMonth
|
|
205
|
-
? 'hover:bg-muted text-foreground'
|
|
206
|
-
: 'text-muted-foreground/40 hover:bg-muted/50'
|
|
207
|
-
}`}>
|
|
208
|
-
{date.getDate()}
|
|
209
|
-
</button>
|
|
210
|
-
)
|
|
211
|
-
})}
|
|
212
|
-
</div>
|
|
213
|
-
))}
|
|
214
|
-
|
|
215
|
-
{/* Today shortcut */}
|
|
216
|
-
<div className="mt-2 pt-2 border-t">
|
|
217
|
-
<button type="button" onClick={() => { handleSelect(new Date()) }}
|
|
218
|
-
className="text-xs text-primary font-medium hover:underline">{t('agenda.calendar.today')}</button>
|
|
219
|
-
</div>
|
|
220
|
-
</div>,
|
|
221
|
-
document.body,
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
224
|
-
)
|
|
225
|
-
}
|