rl-core-front 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.
Files changed (112) hide show
  1. package/README.md +129 -0
  2. package/package.json +113 -0
  3. package/src/_services/api/axios.factory.ts +97 -0
  4. package/src/_services/api/list-query.ts +26 -0
  5. package/src/_services/api/schema.d.ts +2527 -0
  6. package/src/_services/auth/index.ts +127 -0
  7. package/src/_utils/advanced-filter.ts +386 -0
  8. package/src/_utils/calendar.ts +103 -0
  9. package/src/_utils/crop-image.ts +65 -0
  10. package/src/_utils/filter.ts +313 -0
  11. package/src/_utils/format.ts +35 -0
  12. package/src/_utils/initials.ts +24 -0
  13. package/src/_utils/password.ts +7 -0
  14. package/src/components/app-shell.tsx +314 -0
  15. package/src/components/breadcrumbs.tsx +55 -0
  16. package/src/components/index.ts +7 -0
  17. package/src/components/language-selector.tsx +51 -0
  18. package/src/components/password-field.tsx +40 -0
  19. package/src/components/theme-toggle.tsx +27 -0
  20. package/src/components/ui/alert.tsx +27 -0
  21. package/src/components/ui/avatar.tsx +50 -0
  22. package/src/components/ui/badge.tsx +36 -0
  23. package/src/components/ui/button.tsx +62 -0
  24. package/src/components/ui/card.tsx +86 -0
  25. package/src/components/ui/checkbox.tsx +30 -0
  26. package/src/components/ui/column-visibility-modal.tsx +110 -0
  27. package/src/components/ui/confirm-dialog.tsx +69 -0
  28. package/src/components/ui/data-table.tsx +401 -0
  29. package/src/components/ui/date-range-picker.tsx +250 -0
  30. package/src/components/ui/dialog.tsx +138 -0
  31. package/src/components/ui/dropdown-menu.tsx +108 -0
  32. package/src/components/ui/field.tsx +20 -0
  33. package/src/components/ui/filter-chips.tsx +88 -0
  34. package/src/components/ui/filter-field.tsx +137 -0
  35. package/src/components/ui/filter-group.tsx +155 -0
  36. package/src/components/ui/filter-rule.tsx +221 -0
  37. package/src/components/ui/filter-sheet.tsx +201 -0
  38. package/src/components/ui/index.ts +30 -0
  39. package/src/components/ui/input.tsx +25 -0
  40. package/src/components/ui/label.tsx +23 -0
  41. package/src/components/ui/row-actions.tsx +32 -0
  42. package/src/components/ui/select.tsx +97 -0
  43. package/src/components/ui/separator.tsx +31 -0
  44. package/src/components/ui/sheet.tsx +119 -0
  45. package/src/components/ui/sonner.tsx +28 -0
  46. package/src/components/ui/spinner.tsx +12 -0
  47. package/src/components/ui/switch.tsx +29 -0
  48. package/src/components/ui/table.tsx +84 -0
  49. package/src/components/ui/tabs.tsx +52 -0
  50. package/src/components/user-avatar.tsx +54 -0
  51. package/src/contexts/auth-context.tsx +90 -0
  52. package/src/contexts/color-mode-context.tsx +105 -0
  53. package/src/contexts/i18n-context.tsx +76 -0
  54. package/src/contexts/index.ts +6 -0
  55. package/src/contexts/socket-context.tsx +153 -0
  56. package/src/contexts/toast-context.tsx +40 -0
  57. package/src/features/audit/audit-screen.tsx +299 -0
  58. package/src/features/audit/hooks/use-audit-trail.ts +68 -0
  59. package/src/features/audit/services/audit.service.ts +24 -0
  60. package/src/features/dashboard/dashboard-screen.tsx +73 -0
  61. package/src/features/login/components/backup-codes-dialog.tsx +66 -0
  62. package/src/features/login/components/index.ts +5 -0
  63. package/src/features/login/components/login-form.tsx +62 -0
  64. package/src/features/login/components/two-factor-setup.tsx +71 -0
  65. package/src/features/login/components/two-factor-verify.tsx +56 -0
  66. package/src/features/login/hooks/use-login-flow.ts +119 -0
  67. package/src/features/login/login-screen.tsx +83 -0
  68. package/src/features/login/validation/schemas.ts +30 -0
  69. package/src/features/logs/hooks/use-error-logs.ts +65 -0
  70. package/src/features/logs/hooks/use-request-logs.ts +65 -0
  71. package/src/features/logs/logs-screen.tsx +31 -0
  72. package/src/features/logs/panels/error-logs-panel.tsx +183 -0
  73. package/src/features/logs/panels/index.ts +3 -0
  74. package/src/features/logs/panels/request-logs-panel.tsx +124 -0
  75. package/src/features/logs/services/logs.service.ts +68 -0
  76. package/src/features/notifications/components/notification-item.tsx +67 -0
  77. package/src/features/notifications/hooks/use-notifications.ts +78 -0
  78. package/src/features/notifications/notifications-center.tsx +119 -0
  79. package/src/features/notifications/services/notifications.service.ts +38 -0
  80. package/src/features/profile/components/avatar-crop-dialog.tsx +133 -0
  81. package/src/features/profile/components/index.ts +2 -0
  82. package/src/features/profile/force-password-change.tsx +114 -0
  83. package/src/features/profile/profile-screen.tsx +385 -0
  84. package/src/features/rbac/panels/actions-panel.tsx +20 -0
  85. package/src/features/rbac/panels/catalog-panel.tsx +243 -0
  86. package/src/features/rbac/panels/groups-panel.tsx +269 -0
  87. package/src/features/rbac/panels/index.ts +8 -0
  88. package/src/features/rbac/panels/permissions-panel.tsx +204 -0
  89. package/src/features/rbac/panels/resources-panel.tsx +20 -0
  90. package/src/features/rbac/panels/roles-panel.tsx +224 -0
  91. package/src/features/rbac/panels/scopes-panel.tsx +20 -0
  92. package/src/features/rbac/rbac-screen.tsx +80 -0
  93. package/src/features/rbac/services/rbac.service.ts +110 -0
  94. package/src/features/recovery/forgot-password-screen.tsx +69 -0
  95. package/src/features/recovery/reset-password-screen.tsx +163 -0
  96. package/src/features/users/components/user-form-dialog.tsx +187 -0
  97. package/src/features/users/hooks/use-users.ts +181 -0
  98. package/src/features/users/services/users.service.ts +83 -0
  99. package/src/features/users/users-screen.tsx +331 -0
  100. package/src/hooks/use-column-visibility.ts +54 -0
  101. package/src/hooks/use-confirm.tsx +70 -0
  102. package/src/hooks/use-filter-schema.ts +85 -0
  103. package/src/hooks/use-filters.ts +148 -0
  104. package/src/hooks/use-list-query.ts +69 -0
  105. package/src/hooks/use-request.ts +114 -0
  106. package/src/i18n/messages/en.ts +296 -0
  107. package/src/i18n/messages/pt.ts +305 -0
  108. package/src/index.ts +60 -0
  109. package/src/lib/utils.ts +6 -0
  110. package/src/middleware.ts +42 -0
  111. package/src/styles/core.css +142 -0
  112. package/tailwind-preset.ts +103 -0
@@ -0,0 +1,401 @@
1
+ "use client";
2
+
3
+ import {
4
+ ColumnDef,
5
+ columnVisibilityFeature,
6
+ ColumnVisibilityState,
7
+ createPaginatedRowModel,
8
+ createSortedRowModel,
9
+ flexRender,
10
+ OnChangeFn,
11
+ PaginationState,
12
+ RowData,
13
+ rowPaginationFeature,
14
+ rowSortingFeature,
15
+ sortFn_alphanumeric,
16
+ sortFn_basic,
17
+ sortFn_datetime,
18
+ SortingState,
19
+ tableFeatures,
20
+ useTable,
21
+ } from "@tanstack/react-table";
22
+ import {
23
+ ArrowDown,
24
+ ArrowUp,
25
+ ChevronLeft,
26
+ ChevronRight,
27
+ ChevronsUpDown,
28
+ Filter,
29
+ SlidersHorizontal,
30
+ } from "lucide-react";
31
+ import type { JSX } from "react";
32
+ import { useMemo, useState } from "react";
33
+
34
+ import { countConditions } from "#core/_utils/advanced-filter";
35
+ import { activeFilterCount } from "#core/_utils/filter";
36
+ import { Button } from "#core/components/ui/button";
37
+ import { Card } from "#core/components/ui/card";
38
+ import {
39
+ ColumnVisibilityModal,
40
+ ColumnVisibilityOption,
41
+ } from "#core/components/ui/column-visibility-modal";
42
+ import { FilterChips } from "#core/components/ui/filter-chips";
43
+ import { FilterSheet } from "#core/components/ui/filter-sheet";
44
+ import { Spinner } from "#core/components/ui/spinner";
45
+ import {
46
+ Table,
47
+ TableBody,
48
+ TableCell,
49
+ TableHead,
50
+ TableHeader,
51
+ TableRow,
52
+ } from "#core/components/ui/table";
53
+ import { useI18n } from "#core/contexts";
54
+ import { useColumnVisibility } from "#core/hooks/use-column-visibility";
55
+ import type { FiltersState } from "#core/hooks/use-filters";
56
+
57
+ export const dataTableFeatures = tableFeatures({
58
+ columnVisibilityFeature,
59
+ rowSortingFeature,
60
+ rowPaginationFeature,
61
+ sortedRowModel: createSortedRowModel(),
62
+ paginatedRowModel: createPaginatedRowModel(),
63
+ sortFns: {
64
+ alphanumeric: sortFn_alphanumeric,
65
+ basic: sortFn_basic,
66
+ datetime: sortFn_datetime,
67
+ },
68
+ });
69
+
70
+ export type DataTableFeatures = typeof dataTableFeatures;
71
+
72
+ interface DataTableProps<T extends RowData> {
73
+ columns: ColumnDef<DataTableFeatures, T, unknown>[];
74
+ data: T[];
75
+ getRowId?: (row: T) => string;
76
+ storageKey?: string;
77
+ loading?: boolean;
78
+ /** Ações à esquerda da toolbar (ex.: botão "Novo"). */
79
+ toolbar?: React.ReactNode;
80
+ pageSizeOptions?: number[];
81
+ /** Paginação no servidor: informe rowCount, pagination e onPaginationChange. */
82
+ manualPagination?: boolean;
83
+ rowCount?: number;
84
+ pagination?: PaginationState;
85
+ onPaginationChange?: OnChangeFn<PaginationState>;
86
+ /** Ordenação no servidor: informe sorting e onSortingChange. */
87
+ manualSorting?: boolean;
88
+ sorting?: SortingState;
89
+ onSortingChange?: OnChangeFn<SortingState>;
90
+ /** Habilita ordenação (padrão true). Desligue quando o backend ainda não ordena. */
91
+ enableSorting?: boolean;
92
+ /**
93
+ * Filtros dinâmicos, como o `useListQuery` os devolve. Sem esta prop o botão
94
+ * não aparece — listagem sem catálogo continua funcionando igual.
95
+ */
96
+ filters?: FiltersState;
97
+ }
98
+
99
+ /** Extras que uma coluna pode declarar em `meta`. */
100
+ export interface DataTableColumnMeta {
101
+ /** Rótulo no seletor de colunas (quando o header não é texto). */
102
+ label?: string;
103
+ /**
104
+ * Coluna encolhe até o conteúdo em vez de receber parte do espaço sobrando.
105
+ * É o caso da coluna de ações: sem isso o `<table>` a esticava.
106
+ */
107
+ shrink?: boolean;
108
+ }
109
+
110
+ function columnLabel<T extends RowData>(
111
+ col: ColumnDef<DataTableFeatures, T, unknown>,
112
+ id: string,
113
+ ): string {
114
+ const meta = col.meta as DataTableColumnMeta | undefined;
115
+ if (meta?.label) {return meta.label;}
116
+ if (typeof col.header === "string") {return col.header;}
117
+ return id;
118
+ }
119
+
120
+ /**
121
+ * `w-px` + `whitespace-nowrap` é o jeito de fazer uma coluna de tabela encolher
122
+ * até o conteúdo: o navegador trata a largura mínima como o conteúdo e o espaço
123
+ * sobrando vai para as outras colunas.
124
+ */
125
+ function shrinkClass(columnDef: { meta?: unknown }): string | undefined {
126
+ const meta = columnDef.meta as DataTableColumnMeta | undefined;
127
+ return meta?.shrink ? "w-px whitespace-nowrap" : undefined;
128
+ }
129
+
130
+ export function DataTable<T extends RowData>({
131
+ columns,
132
+ data,
133
+ getRowId,
134
+ storageKey,
135
+ loading,
136
+ toolbar,
137
+ pageSizeOptions = [10, 20, 50],
138
+ manualPagination,
139
+ rowCount,
140
+ pagination,
141
+ onPaginationChange,
142
+ manualSorting,
143
+ sorting,
144
+ onSortingChange,
145
+ enableSorting = true,
146
+ filters,
147
+ }: DataTableProps<T>): JSX.Element {
148
+ const { t } = useI18n();
149
+ const [colsOpen, setColsOpen] = useState(false);
150
+ const [filtersOpen, setFiltersOpen] = useState(false);
151
+
152
+ const { hiddenColumns, saveHiddenColumns } = useColumnVisibility(storageKey);
153
+ const columnVisibility = useMemo<ColumnVisibilityState>(() => {
154
+ const v: ColumnVisibilityState = {};
155
+ hiddenColumns.forEach((id) => {
156
+ v[id] = false;
157
+ });
158
+ return v;
159
+ }, [hiddenColumns]);
160
+
161
+ const [internalSorting, setInternalSorting] = useState<SortingState>([]);
162
+ const [internalPagination, setInternalPagination] = useState<PaginationState>(
163
+ {
164
+ pageIndex: 0,
165
+ pageSize: pageSizeOptions[0],
166
+ },
167
+ );
168
+
169
+ const table = useTable({
170
+ features: dataTableFeatures,
171
+ data,
172
+ columns,
173
+ getRowId: getRowId ? (row) => getRowId(row) : undefined,
174
+ state: {
175
+ columnVisibility,
176
+ sorting: manualSorting ? sorting : internalSorting,
177
+ pagination: manualPagination ? pagination : internalPagination,
178
+ },
179
+ onColumnVisibilityChange: (updater) => {
180
+ const next =
181
+ typeof updater === "function" ? updater(columnVisibility) : updater;
182
+ saveHiddenColumns(
183
+ Object.entries(next)
184
+ .filter(([, v]) => v === false)
185
+ .map(([k]) => k),
186
+ );
187
+ },
188
+ onSortingChange: manualSorting ? onSortingChange : setInternalSorting,
189
+ onPaginationChange: manualPagination
190
+ ? onPaginationChange
191
+ : setInternalPagination,
192
+ enableSorting,
193
+ manualSorting,
194
+ manualPagination,
195
+ ...(manualPagination ? { rowCount: rowCount ?? 0 } : {}),
196
+ });
197
+
198
+ const hideable: ColumnVisibilityOption[] = table
199
+ .getAllColumns()
200
+ .filter((c) => c.getCanHide())
201
+ .map((c) => ({
202
+ id: c.id,
203
+ label: columnLabel(
204
+ c.columnDef as ColumnDef<DataTableFeatures, T, unknown>,
205
+ c.id,
206
+ ),
207
+ }));
208
+
209
+ const total = manualPagination
210
+ ? (rowCount ?? 0)
211
+ : table.getFilteredRowModel().rows.length;
212
+ const { pageIndex, pageSize } = table.state.pagination;
213
+ const pageCount = table.getPageCount();
214
+ const colSpan = table.getVisibleLeafColumns().length || 1;
215
+
216
+ const filtersEnabled = Boolean(filters);
217
+ const activeFilters = !filters
218
+ ? 0
219
+ : filters.mode === "advanced"
220
+ ? filters.tree && filters.filter
221
+ ? countConditions(filters.tree)
222
+ : 0
223
+ : activeFilterCount(filters.values);
224
+
225
+ return (
226
+ <div>
227
+ <div className="mb-3 flex items-center justify-between gap-2">
228
+ <div className="flex items-center gap-2">{toolbar}</div>
229
+ <div className="flex items-center gap-2">
230
+ {storageKey && hideable.length > 0 && (
231
+ <Button
232
+ variant="outline"
233
+ size="sm"
234
+ onClick={() => setColsOpen(true)}
235
+ >
236
+ <SlidersHorizontal className="h-4 w-4" />
237
+ {t("table.columnsButton")}
238
+ </Button>
239
+ )}
240
+ {filtersEnabled && (
241
+ <Button
242
+ variant="outline"
243
+ size="sm"
244
+ onClick={() => setFiltersOpen(true)}
245
+ >
246
+ <Filter className="h-4 w-4" />
247
+ {t("filters.button")}
248
+ {activeFilters > 0 && (
249
+ <span className="ml-1 rounded-full bg-primary px-1.5 text-xs text-primary-foreground">
250
+ {activeFilters}
251
+ </span>
252
+ )}
253
+ </Button>
254
+ )}
255
+ </div>
256
+ </div>
257
+
258
+ {filters && <FilterChips filters={filters} />}
259
+
260
+ <Card>
261
+ <Table>
262
+ <TableHeader>
263
+ {table.getHeaderGroups().map((hg) => (
264
+ <TableRow key={hg.id}>
265
+ {hg.headers.map((header) => {
266
+ const canSort = header.column.getCanSort();
267
+ const sorted = header.column.getIsSorted();
268
+ return (
269
+ <TableHead
270
+ key={header.id}
271
+ className={shrinkClass(header.column.columnDef)}
272
+ >
273
+ {header.isPlaceholder ? null : canSort ? (
274
+ <button
275
+ className="flex items-center gap-1 hover:text-foreground"
276
+ onClick={header.column.getToggleSortingHandler()}
277
+ >
278
+ {flexRender(
279
+ header.column.columnDef.header,
280
+ header.getContext(),
281
+ )}
282
+ {sorted === "asc" ? (
283
+ <ArrowUp className="h-3.5 w-3.5" />
284
+ ) : sorted === "desc" ? (
285
+ <ArrowDown className="h-3.5 w-3.5" />
286
+ ) : (
287
+ <ChevronsUpDown className="h-3.5 w-3.5 opacity-50" />
288
+ )}
289
+ </button>
290
+ ) : (
291
+ flexRender(
292
+ header.column.columnDef.header,
293
+ header.getContext(),
294
+ )
295
+ )}
296
+ </TableHead>
297
+ );
298
+ })}
299
+ </TableRow>
300
+ ))}
301
+ </TableHeader>
302
+ <TableBody>
303
+ {loading && (
304
+ <TableRow>
305
+ <TableCell colSpan={colSpan} className="h-24 text-center">
306
+ <Spinner className="mx-auto" />
307
+ </TableCell>
308
+ </TableRow>
309
+ )}
310
+ {!loading && table.getRowModel().rows.length === 0 && (
311
+ <TableRow>
312
+ <TableCell
313
+ colSpan={colSpan}
314
+ className="h-24 text-center text-muted-foreground"
315
+ >
316
+ {t("table.empty")}
317
+ </TableCell>
318
+ </TableRow>
319
+ )}
320
+ {!loading &&
321
+ table.getRowModel().rows.map((row) => (
322
+ <TableRow key={row.id}>
323
+ {row.getVisibleCells().map((cell) => (
324
+ <TableCell
325
+ key={cell.id}
326
+ className={shrinkClass(cell.column.columnDef)}
327
+ >
328
+ {flexRender(
329
+ cell.column.columnDef.cell,
330
+ cell.getContext(),
331
+ )}
332
+ </TableCell>
333
+ ))}
334
+ </TableRow>
335
+ ))}
336
+ </TableBody>
337
+ </Table>
338
+
339
+ <div className="flex flex-col gap-3 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
340
+ <div className="flex items-center gap-3">
341
+ <select
342
+ value={pageSize}
343
+ onChange={(e) => table.setPageSize(Number(e.target.value))}
344
+ className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
345
+ >
346
+ {pageSizeOptions.map((n) => (
347
+ <option key={n} value={n}>
348
+ {n} {t("table.perPage")}
349
+ </option>
350
+ ))}
351
+ </select>
352
+ <span>
353
+ {total} {t("table.results")}
354
+ </span>
355
+ </div>
356
+ <div className="flex items-center gap-2">
357
+ <Button
358
+ variant="outline"
359
+ size="icon"
360
+ disabled={!table.getCanPreviousPage()}
361
+ onClick={() => table.previousPage()}
362
+ >
363
+ <ChevronLeft className="h-4 w-4" />
364
+ </Button>
365
+ <span>
366
+ {pageIndex + 1} / {Math.max(1, pageCount)}
367
+ </span>
368
+ <Button
369
+ variant="outline"
370
+ size="icon"
371
+ disabled={!table.getCanNextPage()}
372
+ onClick={() => table.nextPage()}
373
+ >
374
+ <ChevronRight className="h-4 w-4" />
375
+ </Button>
376
+ </div>
377
+ </div>
378
+ </Card>
379
+
380
+ {storageKey && (
381
+ <ColumnVisibilityModal
382
+ open={colsOpen}
383
+ onOpenChange={setColsOpen}
384
+ columns={hideable}
385
+ hiddenColumns={hiddenColumns}
386
+ onSave={saveHiddenColumns}
387
+ />
388
+ )}
389
+
390
+ {filters && (
391
+ <FilterSheet
392
+ open={filtersOpen}
393
+ onOpenChange={setFiltersOpen}
394
+ filters={filters}
395
+ />
396
+ )}
397
+ </div>
398
+ );
399
+ }
400
+
401
+ export type { ColumnDef, PaginationState, SortingState };
@@ -0,0 +1,250 @@
1
+ "use client";
2
+
3
+ import { Calendar, Check, ChevronLeft, ChevronRight } from "lucide-react";
4
+ import type { JSX } from "react";
5
+ import { useEffect, useRef, useState } from "react";
6
+
7
+ import {
8
+ addDays,
9
+ addMonths,
10
+ formatDay,
11
+ fromIsoDay,
12
+ isSameDay,
13
+ isSameMonth,
14
+ isWithin,
15
+ monthGrid,
16
+ monthLabel,
17
+ today,
18
+ toIsoDay,
19
+ weekdayLabels,
20
+ } from "#core/_utils/calendar";
21
+ import { Button } from "#core/components/ui/button";
22
+ import { useI18n } from "#core/contexts";
23
+ import { cn } from "#core/lib/utils";
24
+
25
+ export interface DateRange {
26
+ /** `"yyyy-MM-dd"`. */
27
+ from?: string;
28
+ to?: string;
29
+ }
30
+
31
+ export interface DateRangePickerProps {
32
+ value: DateRange;
33
+ onChange: (range: DateRange) => void;
34
+ /** Atalhos em dias corridos até hoje (ex.: `[7, 30]`). */
35
+ presets?: number[];
36
+ disabled?: boolean;
37
+ /** Descreve o campo para quem usa leitor de tela. */
38
+ label?: string;
39
+ }
40
+
41
+ const DEFAULT_PRESETS = [7, 15, 30, 90];
42
+
43
+ /**
44
+ * Seleção de período num calendário só.
45
+ *
46
+ * Substitui os dois `<input type="date">`: com eles, escolher "últimos 30 dias"
47
+ * era digitar duas datas e conferir mentalmente qual é a maior. Aqui o primeiro
48
+ * clique marca o início, o segundo o fim (invertido, o componente ordena), e os
49
+ * atalhos cobrem o caso mais comum sem clique nenhum no calendário.
50
+ */
51
+ export function DateRangePicker({
52
+ value,
53
+ onChange,
54
+ presets = DEFAULT_PRESETS,
55
+ disabled,
56
+ label,
57
+ }: DateRangePickerProps): JSX.Element {
58
+ const { t, locale } = useI18n();
59
+ const containerRef = useRef<HTMLDivElement>(null);
60
+ const [open, setOpen] = useState(false);
61
+
62
+ const from = fromIsoDay(value.from);
63
+ const to = fromIsoDay(value.to);
64
+ const [month, setMonth] = useState<Date>(() => from ?? to ?? today());
65
+
66
+ // Fecha ao clicar fora e no ESC — o painel é flutuante e não tem overlay
67
+ // próprio, então quem fecha é o documento.
68
+ useEffect(() => {
69
+ if (!open) {
70
+ return;
71
+ }
72
+ const onPointerDown = (event: MouseEvent): void => {
73
+ if (!containerRef.current?.contains(event.target as Node)) {
74
+ setOpen(false);
75
+ }
76
+ };
77
+ const onKeyDown = (event: KeyboardEvent): void => {
78
+ if (event.key === "Escape") {
79
+ setOpen(false);
80
+ }
81
+ };
82
+ document.addEventListener("mousedown", onPointerDown);
83
+ document.addEventListener("keydown", onKeyDown);
84
+ return () => {
85
+ document.removeEventListener("mousedown", onPointerDown);
86
+ document.removeEventListener("keydown", onKeyDown);
87
+ };
88
+ }, [open]);
89
+
90
+ const pickDay = (day: Date): void => {
91
+ const iso = toIsoDay(day);
92
+ // Intervalo completo (ou vazio) recomeça a seleção; com só o início, o
93
+ // segundo clique fecha o período, ordenando se vier de trás para frente.
94
+ if (!from || (from && to)) {
95
+ onChange({ from: iso, to: undefined });
96
+ return;
97
+ }
98
+ onChange(day < from ? { from: iso, to: value.from } : { from: value.from, to: iso });
99
+ setOpen(false);
100
+ };
101
+
102
+ const applyPreset = (days: number): void => {
103
+ const end = today();
104
+ const start = addDays(end, -days);
105
+ onChange({ from: toIsoDay(start), to: toIsoDay(end) });
106
+ setMonth(end);
107
+ setOpen(false);
108
+ };
109
+
110
+ const isPresetActive = (days: number): boolean =>
111
+ Boolean(
112
+ from && to && isSameDay(to, today()) && isSameDay(from, addDays(to, -days)),
113
+ );
114
+
115
+ const dayClass = (day: Date): string => {
116
+ if ((from && isSameDay(day, from)) || (to && isSameDay(day, to))) {
117
+ return "bg-primary text-primary-foreground font-semibold";
118
+ }
119
+ if (from && to && isWithin(day, from, to)) {
120
+ return "bg-accent text-accent-foreground";
121
+ }
122
+ return isSameMonth(day, month) ? "" : "text-muted-foreground/60";
123
+ };
124
+
125
+ const summary = (): string => {
126
+ if (value.from && value.to) {
127
+ return `${formatDay(value.from, locale)} – ${formatDay(value.to, locale)}`;
128
+ }
129
+ if (value.from) {
130
+ return `${t("filters.fromChip")} ${formatDay(value.from, locale)}`;
131
+ }
132
+ if (value.to) {
133
+ return `${t("filters.toChip")} ${formatDay(value.to, locale)}`;
134
+ }
135
+ return t("filters.selectPeriod");
136
+ };
137
+
138
+ const hasValue = Boolean(value.from || value.to);
139
+
140
+ return (
141
+ <div className="relative" ref={containerRef}>
142
+ <button
143
+ type="button"
144
+ disabled={disabled}
145
+ aria-haspopup="dialog"
146
+ aria-expanded={open}
147
+ aria-label={label}
148
+ onClick={() => setOpen((current) => !current)}
149
+ className="flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
150
+ >
151
+ <span className={cn("truncate", !hasValue && "text-muted-foreground")}>
152
+ {summary()}
153
+ </span>
154
+ <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
155
+ </button>
156
+
157
+ {open && (
158
+ <div
159
+ role="dialog"
160
+ className="absolute left-0 z-50 mt-2 w-[19rem] rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-lg"
161
+ >
162
+ {presets.length > 0 && (
163
+ <div className="mb-3 flex flex-wrap gap-1.5">
164
+ {presets.map((days) => (
165
+ <button
166
+ key={days}
167
+ type="button"
168
+ onClick={() => applyPreset(days)}
169
+ className={cn(
170
+ "flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors",
171
+ isPresetActive(days)
172
+ ? "border-primary bg-primary text-primary-foreground"
173
+ : "border-border hover:border-ring",
174
+ )}
175
+ >
176
+ {isPresetActive(days) && <Check className="h-3 w-3" />}
177
+ {t("filters.lastDays", { days })}
178
+ </button>
179
+ ))}
180
+ </div>
181
+ )}
182
+
183
+ <div className="mb-2 flex items-center justify-between">
184
+ <Button
185
+ type="button"
186
+ variant="ghost"
187
+ size="iconSm"
188
+ aria-label={t("filters.previousMonth")}
189
+ onClick={() => setMonth(addMonths(month, -1))}
190
+ >
191
+ <ChevronLeft className="h-4 w-4" />
192
+ </Button>
193
+ <span className="text-sm font-medium capitalize">
194
+ {monthLabel(month, locale)}
195
+ </span>
196
+ <Button
197
+ type="button"
198
+ variant="ghost"
199
+ size="iconSm"
200
+ aria-label={t("filters.nextMonth")}
201
+ onClick={() => setMonth(addMonths(month, 1))}
202
+ >
203
+ <ChevronRight className="h-4 w-4" />
204
+ </Button>
205
+ </div>
206
+
207
+ <div className="mb-1 grid grid-cols-7 text-center text-xs text-muted-foreground">
208
+ {weekdayLabels(locale).map((weekday) => (
209
+ <span key={weekday} className="capitalize">
210
+ {weekday}
211
+ </span>
212
+ ))}
213
+ </div>
214
+
215
+ <div className="grid grid-cols-7 gap-y-1 justify-items-center">
216
+ {monthGrid(month).map((day) => (
217
+ <button
218
+ key={day.toISOString()}
219
+ type="button"
220
+ onClick={() => pickDay(day)}
221
+ className={cn(
222
+ "flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors hover:bg-accent",
223
+ dayClass(day),
224
+ )}
225
+ >
226
+ {day.getDate()}
227
+ </button>
228
+ ))}
229
+ </div>
230
+
231
+ {hasValue && (
232
+ <div className="mt-2 flex justify-end border-t border-border pt-2">
233
+ <Button
234
+ type="button"
235
+ variant="ghost"
236
+ size="sm"
237
+ onClick={() => {
238
+ onChange({});
239
+ setOpen(false);
240
+ }}
241
+ >
242
+ {t("filters.clear")}
243
+ </Button>
244
+ </div>
245
+ )}
246
+ </div>
247
+ )}
248
+ </div>
249
+ );
250
+ }