@spree/dashboard-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +86 -0
- package/src/hooks/use-copy-to-clipboard.ts +44 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-scrolled.ts +30 -0
- package/src/index.ts +99 -0
- package/src/lib/date-locale.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/validation-messages.ts +19 -0
- package/src/spree/address-block.tsx +74 -0
- package/src/spree/back-button.tsx +48 -0
- package/src/spree/bulk-dialog.tsx +71 -0
- package/src/spree/bulk-price-table.tsx +254 -0
- package/src/spree/calculator-summary.tsx +120 -0
- package/src/spree/color-picker.tsx +202 -0
- package/src/spree/confirm-dialog.tsx +82 -0
- package/src/spree/copy-to-clipboard-button.tsx +90 -0
- package/src/spree/country-flag.tsx +30 -0
- package/src/spree/data-grid/cells.tsx +415 -0
- package/src/spree/data-grid/context.ts +59 -0
- package/src/spree/data-grid/data-grid.tsx +214 -0
- package/src/spree/data-grid/fill-handle.tsx +153 -0
- package/src/spree/data-grid/helpers.ts +18 -0
- package/src/spree/data-grid/index.ts +5 -0
- package/src/spree/data-grid/types.ts +36 -0
- package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
- package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
- package/src/spree/drag-handle.tsx +51 -0
- package/src/spree/form-actions.tsx +140 -0
- package/src/spree/json-preview-drawer.tsx +254 -0
- package/src/spree/json-value-view.tsx +149 -0
- package/src/spree/language-menu-items.tsx +54 -0
- package/src/spree/metadata/metadata-card.tsx +62 -0
- package/src/spree/relative-time.tsx +49 -0
- package/src/spree/resource-combobox.tsx +108 -0
- package/src/spree/resource-layout.tsx +31 -0
- package/src/spree/resource-multi-autocomplete.tsx +180 -0
- package/src/spree/resource-name-cell.tsx +44 -0
- package/src/spree/route-error-boundary.tsx +102 -0
- package/src/spree/row-actions.tsx +85 -0
- package/src/spree/row-click-bridge.tsx +17 -0
- package/src/spree/secret-input.tsx +137 -0
- package/src/spree/storefront-visible-switch.tsx +58 -0
- package/src/spree/tag-list.tsx +26 -0
- package/src/spree/theme-provider.tsx +83 -0
- package/src/spree/theme-toggle.tsx +35 -0
- package/src/styles.css +338 -0
- package/src/ui/attachment.tsx +187 -0
- package/src/ui/avatar.tsx +94 -0
- package/src/ui/badge.tsx +161 -0
- package/src/ui/breadcrumb.tsx +109 -0
- package/src/ui/button.tsx +60 -0
- package/src/ui/calendar.tsx +66 -0
- package/src/ui/card.tsx +72 -0
- package/src/ui/chart.tsx +94 -0
- package/src/ui/checkbox.tsx +50 -0
- package/src/ui/collapsible.tsx +18 -0
- package/src/ui/combobox.tsx +270 -0
- package/src/ui/command.tsx +172 -0
- package/src/ui/data-table.tsx +60 -0
- package/src/ui/date-picker.tsx +253 -0
- package/src/ui/date-range-picker.tsx +150 -0
- package/src/ui/dialog.tsx +214 -0
- package/src/ui/dropdown-menu.tsx +345 -0
- package/src/ui/empty.tsx +94 -0
- package/src/ui/field.tsx +221 -0
- package/src/ui/input-group.tsx +141 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/label.tsx +29 -0
- package/src/ui/pagination.tsx +123 -0
- package/src/ui/popover.tsx +76 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/rich-text-editor.tsx +263 -0
- package/src/ui/select.tsx +213 -0
- package/src/ui/separator.tsx +31 -0
- package/src/ui/sheet.tsx +208 -0
- package/src/ui/sidebar.tsx +679 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/slot.tsx +35 -0
- package/src/ui/sonner.tsx +47 -0
- package/src/ui/switch.tsx +39 -0
- package/src/ui/textarea.tsx +18 -0
- package/src/ui/tooltip.tsx +100 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../lib/utils'
|
|
3
|
+
|
|
4
|
+
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="overflow-x-auto">
|
|
7
|
+
<table className={cn('w-full align-top text-foreground', className)} {...props} />
|
|
8
|
+
</div>
|
|
9
|
+
)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
|
13
|
+
return <thead className={cn('align-bottom', className)} {...props} />
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
|
17
|
+
return <tbody className={cn('align-middle', className)} {...props} />
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
|
21
|
+
return (
|
|
22
|
+
<tr className={cn('group/row hover:bg-muted/60 last:*:border-b-0', className)} {...props} />
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
|
27
|
+
return (
|
|
28
|
+
<th
|
|
29
|
+
className={cn(
|
|
30
|
+
'text-left text-sm font-medium text-muted-foreground bg-muted/50 p-2 border-b border-border whitespace-nowrap first:pl-4 last:pr-4',
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
|
39
|
+
return (
|
|
40
|
+
<td
|
|
41
|
+
className={cn(
|
|
42
|
+
'py-3 px-2 border-b border-border align-middle first:pl-4 last:pr-4 group-last/row:first:rounded-bl-xl group-last/row:last:rounded-br-xl',
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function TableEmpty({ children, colSpan }: { children: ReactNode; colSpan: number }) {
|
|
51
|
+
return (
|
|
52
|
+
<tr>
|
|
53
|
+
<td colSpan={colSpan} className="py-12 text-center text-muted-foreground">
|
|
54
|
+
{children}
|
|
55
|
+
</td>
|
|
56
|
+
</tr>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow }
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { format, parse, parseISO } from 'date-fns'
|
|
2
|
+
import { formatInTimeZone, fromZonedTime, toZonedTime } from 'date-fns-tz'
|
|
3
|
+
import i18n from 'i18next'
|
|
4
|
+
import { CalendarIcon, XIcon } from 'lucide-react'
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import { activeDateLocale } from '../lib/date-locale'
|
|
7
|
+
import { cn } from '../lib/utils'
|
|
8
|
+
import { Button } from './button'
|
|
9
|
+
import { Calendar } from './calendar'
|
|
10
|
+
import { Input } from './input'
|
|
11
|
+
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
|
12
|
+
|
|
13
|
+
interface DatePickerProps {
|
|
14
|
+
value?: string | null
|
|
15
|
+
onChange?: (value: string | null) => void
|
|
16
|
+
placeholder?: string
|
|
17
|
+
className?: string
|
|
18
|
+
disabled?: boolean
|
|
19
|
+
includeTime?: boolean
|
|
20
|
+
/**
|
|
21
|
+
* IANA timezone (e.g. `Europe/Berlin`) used to interpret datetime values.
|
|
22
|
+
* Defaults to the browser's resolved timezone. **Pass the store timezone
|
|
23
|
+
* for any admin-facing surface** so that wall-clock times mean the same
|
|
24
|
+
* thing for every admin regardless of where they're logged in from. Use
|
|
25
|
+
* `<StoreDatePicker>` (in `components/spree/`) to do this automatically.
|
|
26
|
+
*
|
|
27
|
+
* Date-only mode (`includeTime=false`) is timezone-agnostic — values are
|
|
28
|
+
* plain `yyyy-MM-dd` strings with no clock component.
|
|
29
|
+
*/
|
|
30
|
+
timezone?: string
|
|
31
|
+
/**
|
|
32
|
+
* Render the calendar panel inline (absolute-positioned beneath the
|
|
33
|
+
* trigger) instead of via Base UI's `<Popover>` Portal. Set this on any
|
|
34
|
+
* usage nested inside a `<Sheet>` — Base UI's Popover Portal silently
|
|
35
|
+
* fails to mount inside deeply nested portal trees, so callers in form
|
|
36
|
+
* sheets / edit drawers must opt in. Filter panels and page-level forms
|
|
37
|
+
* keep the default Popover behavior.
|
|
38
|
+
*/
|
|
39
|
+
inline?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/
|
|
43
|
+
|
|
44
|
+
// Parse the inbound value into a Date whose **browser-local components**
|
|
45
|
+
// match the *store-local* date/time the value represents:
|
|
46
|
+
// - `yyyy-MM-dd` (date-only): timezone-agnostic; parse in browser-local.
|
|
47
|
+
// - Full ISO timestamp (datetime): UTC instant reinterpreted in store TZ.
|
|
48
|
+
function parseValue(value: string | null | undefined, tz: string): Date | undefined {
|
|
49
|
+
if (!value) return undefined
|
|
50
|
+
if (DATE_ONLY.test(value)) return parse(value, 'yyyy-MM-dd', new Date())
|
|
51
|
+
return toZonedTime(parseISO(value), tz)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Format the value for the trigger button. Date-only uses the locally-anchored
|
|
55
|
+
// Date; datetime uses store-TZ formatting on the original UTC instant.
|
|
56
|
+
function formatTriggerLabel(
|
|
57
|
+
value: string | null | undefined,
|
|
58
|
+
date: Date | undefined,
|
|
59
|
+
tz: string,
|
|
60
|
+
includeTime: boolean,
|
|
61
|
+
placeholder: string,
|
|
62
|
+
): string {
|
|
63
|
+
if (!date || Number.isNaN(date.getTime())) return placeholder
|
|
64
|
+
const locale = activeDateLocale()
|
|
65
|
+
if (includeTime && value && !DATE_ONLY.test(value)) {
|
|
66
|
+
return formatInTimeZone(parseISO(value), tz, 'PPP p', { locale })
|
|
67
|
+
}
|
|
68
|
+
return format(date, includeTime ? 'PPP p' : 'PPP', { locale })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function DatePicker({
|
|
72
|
+
value,
|
|
73
|
+
onChange,
|
|
74
|
+
placeholder = i18n.t('admin.common.pick_date'),
|
|
75
|
+
className,
|
|
76
|
+
disabled = false,
|
|
77
|
+
includeTime = false,
|
|
78
|
+
timezone,
|
|
79
|
+
inline = false,
|
|
80
|
+
}: DatePickerProps) {
|
|
81
|
+
const [open, setOpen] = useState(false)
|
|
82
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
83
|
+
|
|
84
|
+
// Browser TZ is the fallback — used in non-admin contexts (Storybook,
|
|
85
|
+
// isolated tests) where there's no store context to pull from.
|
|
86
|
+
const tz = useMemo(
|
|
87
|
+
() => timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
|
|
88
|
+
[timezone],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// The Calendar and trigger label both read browser-local components, so
|
|
92
|
+
// anchoring them to store-local wall-clock is what makes the UI correct.
|
|
93
|
+
const date = useMemo(() => parseValue(value, tz), [value, tz])
|
|
94
|
+
const isValidDate = !!date && !Number.isNaN(date.getTime())
|
|
95
|
+
|
|
96
|
+
// Inline mode only: close on outside pointerdown / Escape. Popover mode
|
|
97
|
+
// gets this behavior from Base UI.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!inline || !open) return
|
|
100
|
+
function handle(event: PointerEvent) {
|
|
101
|
+
const target = event.target as Node | null
|
|
102
|
+
if (!target || !containerRef.current) return
|
|
103
|
+
if (!containerRef.current.contains(target)) setOpen(false)
|
|
104
|
+
}
|
|
105
|
+
function handleKey(event: KeyboardEvent) {
|
|
106
|
+
if (event.key === 'Escape') setOpen(false)
|
|
107
|
+
}
|
|
108
|
+
document.addEventListener('pointerdown', handle)
|
|
109
|
+
document.addEventListener('keydown', handleKey)
|
|
110
|
+
return () => {
|
|
111
|
+
document.removeEventListener('pointerdown', handle)
|
|
112
|
+
document.removeEventListener('keydown', handleKey)
|
|
113
|
+
}
|
|
114
|
+
}, [inline, open])
|
|
115
|
+
|
|
116
|
+
const handleSelect = (selected: Date | undefined) => {
|
|
117
|
+
if (!selected) {
|
|
118
|
+
onChange?.(null)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!includeTime) {
|
|
123
|
+
// Date-only: emit `yyyy-MM-dd` using browser-local components. The
|
|
124
|
+
// user clicked a specific calendar day; the day number is what
|
|
125
|
+
// matters, not the timezone.
|
|
126
|
+
onChange?.(format(selected, 'yyyy-MM-dd'))
|
|
127
|
+
setOpen(false)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Datetime: the picked Date has browser-local components for the
|
|
132
|
+
// selected day at (preserved or zero) hours/minutes. Reinterpret those
|
|
133
|
+
// wall-clock components as **store-local time**, then emit the UTC
|
|
134
|
+
// instant. `fromZonedTime` does exactly this conversion.
|
|
135
|
+
if (date && isValidDate) {
|
|
136
|
+
selected.setHours(date.getHours(), date.getMinutes())
|
|
137
|
+
}
|
|
138
|
+
onChange?.(fromZonedTime(selected, tz).toISOString())
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
142
|
+
const raw = e.target.value
|
|
143
|
+
if (!raw) {
|
|
144
|
+
onChange?.(null)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
const [hours, minutes] = raw.split(':').map(Number)
|
|
148
|
+
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return
|
|
149
|
+
const d = date && isValidDate ? new Date(date) : new Date()
|
|
150
|
+
d.setHours(hours, minutes, 0, 0)
|
|
151
|
+
onChange?.(fromZonedTime(d, tz).toISOString())
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const handleClear = (e: React.SyntheticEvent) => {
|
|
155
|
+
e.preventDefault()
|
|
156
|
+
e.stopPropagation()
|
|
157
|
+
onChange?.(null)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const timeValue = useMemo(
|
|
161
|
+
() =>
|
|
162
|
+
isValidDate && date
|
|
163
|
+
? `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
|
164
|
+
: '',
|
|
165
|
+
[isValidDate, date],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const triggerLabel = useMemo(
|
|
169
|
+
() => formatTriggerLabel(value, date, tz, includeTime, placeholder),
|
|
170
|
+
[value, date, tz, includeTime, placeholder],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const triggerChildren = (
|
|
174
|
+
<>
|
|
175
|
+
<CalendarIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
176
|
+
<span className="flex-1 truncate">{triggerLabel}</span>
|
|
177
|
+
{isValidDate && (
|
|
178
|
+
<span
|
|
179
|
+
role="button"
|
|
180
|
+
tabIndex={0}
|
|
181
|
+
aria-label={i18n.t('admin.a11y.clear_date')}
|
|
182
|
+
className="inline-flex size-4 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
183
|
+
onClick={handleClear}
|
|
184
|
+
onKeyDown={(e) => {
|
|
185
|
+
if (e.key === 'Enter' || e.key === ' ') handleClear(e)
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<XIcon className="size-3.5" />
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
</>
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const panelChildren = (
|
|
195
|
+
<>
|
|
196
|
+
<Calendar
|
|
197
|
+
mode="single"
|
|
198
|
+
selected={isValidDate ? date : undefined}
|
|
199
|
+
onSelect={handleSelect}
|
|
200
|
+
defaultMonth={isValidDate ? date : undefined}
|
|
201
|
+
/>
|
|
202
|
+
{includeTime && (
|
|
203
|
+
<div className="border-t border-border px-3 py-2">
|
|
204
|
+
<Input type="time" value={timeValue} onChange={handleTimeChange} />
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</>
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if (inline) {
|
|
211
|
+
return (
|
|
212
|
+
<div ref={containerRef} className={cn('relative', className)}>
|
|
213
|
+
<Button
|
|
214
|
+
type="button"
|
|
215
|
+
variant="outline"
|
|
216
|
+
data-empty={!isValidDate}
|
|
217
|
+
disabled={disabled}
|
|
218
|
+
aria-haspopup="dialog"
|
|
219
|
+
aria-expanded={open}
|
|
220
|
+
onClick={() => setOpen((v) => !v)}
|
|
221
|
+
className="w-full justify-start"
|
|
222
|
+
>
|
|
223
|
+
{triggerChildren}
|
|
224
|
+
</Button>
|
|
225
|
+
{open && (
|
|
226
|
+
<div
|
|
227
|
+
role="dialog"
|
|
228
|
+
aria-label={placeholder}
|
|
229
|
+
className="absolute top-full left-0 z-50 mt-2 w-auto rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-md"
|
|
230
|
+
>
|
|
231
|
+
{panelChildren}
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
240
|
+
<PopoverTrigger asChild disabled={disabled}>
|
|
241
|
+
<Button type="button" variant="outline" data-empty={!isValidDate} className={className}>
|
|
242
|
+
{triggerChildren}
|
|
243
|
+
</Button>
|
|
244
|
+
</PopoverTrigger>
|
|
245
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
246
|
+
{panelChildren}
|
|
247
|
+
</PopoverContent>
|
|
248
|
+
</Popover>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type { DatePickerProps }
|
|
253
|
+
export { DatePicker }
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { format, startOfMonth, startOfYear, subDays } from 'date-fns'
|
|
2
|
+
import { CalendarIcon, ChevronDownIcon } from 'lucide-react'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import type { DateRange as DayPickerDateRange } from 'react-day-picker'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { activeDateLocale } from '../lib/date-locale'
|
|
7
|
+
import { cn } from '../lib/utils'
|
|
8
|
+
import { Button } from './button'
|
|
9
|
+
import { Calendar } from './calendar'
|
|
10
|
+
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
|
11
|
+
|
|
12
|
+
export interface DateRange {
|
|
13
|
+
from: Date
|
|
14
|
+
to: Date
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type PresetKey = '7d' | '14d' | '30d' | '90d' | 'this_month' | 'ytd' | 'custom'
|
|
18
|
+
|
|
19
|
+
interface Preset {
|
|
20
|
+
key: PresetKey
|
|
21
|
+
labelKey: string
|
|
22
|
+
value: () => DateRange
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const presets: Preset[] = [
|
|
26
|
+
{
|
|
27
|
+
key: '7d',
|
|
28
|
+
labelKey: 'admin.components.date_range_picker.presets.last_7_days',
|
|
29
|
+
value: () => ({ from: subDays(new Date(), 7), to: new Date() }),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
key: '14d',
|
|
33
|
+
labelKey: 'admin.components.date_range_picker.presets.last_14_days',
|
|
34
|
+
value: () => ({ from: subDays(new Date(), 14), to: new Date() }),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
key: '30d',
|
|
38
|
+
labelKey: 'admin.components.date_range_picker.presets.last_30_days',
|
|
39
|
+
value: () => ({ from: subDays(new Date(), 30), to: new Date() }),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: '90d',
|
|
43
|
+
labelKey: 'admin.components.date_range_picker.presets.last_90_days',
|
|
44
|
+
value: () => ({ from: subDays(new Date(), 90), to: new Date() }),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'this_month',
|
|
48
|
+
labelKey: 'admin.components.date_range_picker.presets.this_month',
|
|
49
|
+
value: () => ({ from: startOfMonth(new Date()), to: new Date() }),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: 'ytd',
|
|
53
|
+
labelKey: 'admin.components.date_range_picker.presets.year_to_date',
|
|
54
|
+
value: () => ({ from: startOfYear(new Date()), to: new Date() }),
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
interface DateRangePickerProps {
|
|
59
|
+
value: DateRange
|
|
60
|
+
onChange: (range: DateRange) => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
|
64
|
+
const { t } = useTranslation()
|
|
65
|
+
const [open, setOpen] = useState(false)
|
|
66
|
+
const [activePreset, setActivePreset] = useState<PresetKey>('30d')
|
|
67
|
+
const [calendarRange, setCalendarRange] = useState<DayPickerDateRange | undefined>()
|
|
68
|
+
|
|
69
|
+
const activePresetLabelKey =
|
|
70
|
+
presets.find((p) => p.key === activePreset)?.labelKey ??
|
|
71
|
+
'admin.components.date_range_picker.presets.last_30_days'
|
|
72
|
+
|
|
73
|
+
const dateLocale = activeDateLocale()
|
|
74
|
+
const triggerLabel =
|
|
75
|
+
activePreset === 'custom'
|
|
76
|
+
? `${format(value.from, 'MMM d', { locale: dateLocale })} – ${format(value.to, 'MMM d', { locale: dateLocale })}`
|
|
77
|
+
: t(activePresetLabelKey)
|
|
78
|
+
|
|
79
|
+
function selectPreset(preset: Preset) {
|
|
80
|
+
const range = preset.value()
|
|
81
|
+
setActivePreset(preset.key)
|
|
82
|
+
setCalendarRange({ from: range.from, to: range.to })
|
|
83
|
+
onChange(range)
|
|
84
|
+
setOpen(false)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function applyCustomRange() {
|
|
88
|
+
if (calendarRange?.from && calendarRange?.to) {
|
|
89
|
+
setActivePreset('custom')
|
|
90
|
+
onChange({ from: calendarRange.from, to: calendarRange.to })
|
|
91
|
+
setOpen(false)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const canApply = calendarRange?.from && calendarRange?.to
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Popover
|
|
99
|
+
open={open}
|
|
100
|
+
onOpenChange={(nextOpen) => {
|
|
101
|
+
setOpen(nextOpen)
|
|
102
|
+
if (nextOpen) {
|
|
103
|
+
setCalendarRange({ from: value.from, to: value.to })
|
|
104
|
+
}
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<PopoverTrigger asChild>
|
|
108
|
+
<Button size="sm" variant="outline" className="h-8 gap-2 text-sm font-normal">
|
|
109
|
+
<CalendarIcon className="size-3.5" />
|
|
110
|
+
{triggerLabel}
|
|
111
|
+
<ChevronDownIcon className="size-3.5 text-muted-foreground" />
|
|
112
|
+
</Button>
|
|
113
|
+
</PopoverTrigger>
|
|
114
|
+
<PopoverContent align="end" className="w-auto p-0">
|
|
115
|
+
<div className="flex">
|
|
116
|
+
<div className="flex flex-col border-r py-2">
|
|
117
|
+
{presets.map((preset) => (
|
|
118
|
+
<button
|
|
119
|
+
key={preset.key}
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={() => selectPreset(preset)}
|
|
122
|
+
className={cn(
|
|
123
|
+
'whitespace-nowrap px-4 py-1.5 text-left text-sm transition-colors hover:bg-accent',
|
|
124
|
+
activePreset === preset.key && 'font-medium text-foreground',
|
|
125
|
+
activePreset !== preset.key && 'text-muted-foreground',
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
{t(preset.labelKey)}
|
|
129
|
+
</button>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
<div className="flex flex-col p-3">
|
|
133
|
+
<Calendar
|
|
134
|
+
mode="range"
|
|
135
|
+
defaultMonth={calendarRange?.from}
|
|
136
|
+
selected={calendarRange}
|
|
137
|
+
onSelect={setCalendarRange}
|
|
138
|
+
numberOfMonths={2}
|
|
139
|
+
/>
|
|
140
|
+
<div className="flex justify-end border-t pt-3">
|
|
141
|
+
<Button size="sm" disabled={!canApply} onClick={applyCustomRange}>
|
|
142
|
+
{t('admin.actions.apply')}
|
|
143
|
+
</Button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</PopoverContent>
|
|
148
|
+
</Popover>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
|
|
2
|
+
import i18n from 'i18next'
|
|
3
|
+
import { XIcon } from 'lucide-react'
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { cn } from '../lib/utils'
|
|
6
|
+
import { Button } from './button'
|
|
7
|
+
|
|
8
|
+
function Dialog({
|
|
9
|
+
children,
|
|
10
|
+
open,
|
|
11
|
+
defaultOpen,
|
|
12
|
+
onOpenChange,
|
|
13
|
+
modal,
|
|
14
|
+
...props
|
|
15
|
+
}: {
|
|
16
|
+
children?: React.ReactNode
|
|
17
|
+
open?: boolean
|
|
18
|
+
defaultOpen?: boolean
|
|
19
|
+
onOpenChange?: (open: boolean, eventDetails: DialogPrimitive.Root.ChangeEventDetails) => void
|
|
20
|
+
modal?: boolean | 'trap-focus'
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<DialogPrimitive.Root
|
|
24
|
+
data-slot="dialog"
|
|
25
|
+
open={open}
|
|
26
|
+
defaultOpen={defaultOpen}
|
|
27
|
+
onOpenChange={onOpenChange}
|
|
28
|
+
modal={modal}
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</DialogPrimitive.Root>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function DialogTrigger({
|
|
37
|
+
asChild,
|
|
38
|
+
children,
|
|
39
|
+
...props
|
|
40
|
+
}: React.ComponentProps<typeof DialogPrimitive.Trigger> & {
|
|
41
|
+
asChild?: boolean
|
|
42
|
+
}) {
|
|
43
|
+
if (asChild && React.isValidElement(children)) {
|
|
44
|
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" render={children} {...props} />
|
|
45
|
+
}
|
|
46
|
+
return (
|
|
47
|
+
<DialogPrimitive.Trigger data-slot="dialog-trigger" {...props}>
|
|
48
|
+
{children}
|
|
49
|
+
</DialogPrimitive.Trigger>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function DialogPortal({ children, ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
54
|
+
return (
|
|
55
|
+
<DialogPrimitive.Portal data-slot="dialog-portal" {...props}>
|
|
56
|
+
{children}
|
|
57
|
+
</DialogPrimitive.Portal>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function DialogClose({
|
|
62
|
+
asChild,
|
|
63
|
+
children,
|
|
64
|
+
...props
|
|
65
|
+
}: React.ComponentProps<typeof DialogPrimitive.Close> & {
|
|
66
|
+
asChild?: boolean
|
|
67
|
+
}) {
|
|
68
|
+
if (asChild && React.isValidElement(children)) {
|
|
69
|
+
return <DialogPrimitive.Close data-slot="dialog-close" render={children} {...props} />
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<DialogPrimitive.Close data-slot="dialog-close" {...props}>
|
|
73
|
+
{children}
|
|
74
|
+
</DialogPrimitive.Close>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function DialogOverlay({
|
|
79
|
+
className,
|
|
80
|
+
...props
|
|
81
|
+
}: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {
|
|
82
|
+
return (
|
|
83
|
+
<DialogPrimitive.Backdrop
|
|
84
|
+
data-slot="dialog-overlay"
|
|
85
|
+
className={cn(
|
|
86
|
+
'fixed inset-0 isolate z-50 bg-gray-100/75 dark:bg-black/60 duration-100 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 transition-opacity',
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
{...props}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function DialogContent({
|
|
95
|
+
className,
|
|
96
|
+
children,
|
|
97
|
+
showCloseButton = true,
|
|
98
|
+
...props
|
|
99
|
+
}: React.ComponentProps<typeof DialogPrimitive.Popup> & {
|
|
100
|
+
showCloseButton?: boolean
|
|
101
|
+
}) {
|
|
102
|
+
return (
|
|
103
|
+
<DialogPortal>
|
|
104
|
+
<DialogOverlay />
|
|
105
|
+
<DialogPrimitive.Popup
|
|
106
|
+
data-slot="dialog-content"
|
|
107
|
+
className={cn(
|
|
108
|
+
'fixed top-1/2 left-1/2 z-50 flex w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-border bg-card text-foreground text-sm shadow-lg duration-100 outline-none sm:max-w-[500px] data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform]',
|
|
109
|
+
className,
|
|
110
|
+
)}
|
|
111
|
+
style={{ maxHeight: '90vh' }}
|
|
112
|
+
{...props}
|
|
113
|
+
>
|
|
114
|
+
{children}
|
|
115
|
+
{showCloseButton && (
|
|
116
|
+
<DialogPrimitive.Close
|
|
117
|
+
data-slot="dialog-close"
|
|
118
|
+
render={
|
|
119
|
+
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
|
|
120
|
+
<XIcon />
|
|
121
|
+
<span className="sr-only">{i18n.t('admin.actions.close')}</span>
|
|
122
|
+
</Button>
|
|
123
|
+
}
|
|
124
|
+
/>
|
|
125
|
+
)}
|
|
126
|
+
</DialogPrimitive.Popup>
|
|
127
|
+
</DialogPortal>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
data-slot="dialog-header"
|
|
135
|
+
className={cn('flex flex-col gap-1.5 border-b p-4', className)}
|
|
136
|
+
{...props}
|
|
137
|
+
/>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function DialogBody({ className, ...props }: React.ComponentProps<'div'>) {
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
data-slot="dialog-body"
|
|
145
|
+
className={cn('flex-auto overflow-y-auto p-4', className)}
|
|
146
|
+
{...props}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function DialogFooter({
|
|
152
|
+
className,
|
|
153
|
+
showCloseButton = false,
|
|
154
|
+
children,
|
|
155
|
+
...props
|
|
156
|
+
}: React.ComponentProps<'div'> & {
|
|
157
|
+
showCloseButton?: boolean
|
|
158
|
+
}) {
|
|
159
|
+
return (
|
|
160
|
+
<div
|
|
161
|
+
data-slot="dialog-footer"
|
|
162
|
+
className={cn(
|
|
163
|
+
'flex flex-col-reverse gap-2 border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
|
|
164
|
+
className,
|
|
165
|
+
)}
|
|
166
|
+
{...props}
|
|
167
|
+
>
|
|
168
|
+
{children}
|
|
169
|
+
{showCloseButton && (
|
|
170
|
+
<DialogPrimitive.Close render={<Button>{i18n.t('admin.actions.close')}</Button>} />
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
177
|
+
return (
|
|
178
|
+
<DialogPrimitive.Title
|
|
179
|
+
data-slot="dialog-title"
|
|
180
|
+
className={cn('text-base leading-none font-medium', className)}
|
|
181
|
+
{...props}
|
|
182
|
+
/>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function DialogDescription({
|
|
187
|
+
className,
|
|
188
|
+
...props
|
|
189
|
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
190
|
+
return (
|
|
191
|
+
<DialogPrimitive.Description
|
|
192
|
+
data-slot="dialog-description"
|
|
193
|
+
className={cn(
|
|
194
|
+
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
|
|
195
|
+
className,
|
|
196
|
+
)}
|
|
197
|
+
{...props}
|
|
198
|
+
/>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export {
|
|
203
|
+
Dialog,
|
|
204
|
+
DialogBody,
|
|
205
|
+
DialogClose,
|
|
206
|
+
DialogContent,
|
|
207
|
+
DialogDescription,
|
|
208
|
+
DialogFooter,
|
|
209
|
+
DialogHeader,
|
|
210
|
+
DialogOverlay,
|
|
211
|
+
DialogPortal,
|
|
212
|
+
DialogTitle,
|
|
213
|
+
DialogTrigger,
|
|
214
|
+
}
|