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.
- package/README.md +129 -0
- package/package.json +113 -0
- package/src/_services/api/axios.factory.ts +97 -0
- package/src/_services/api/list-query.ts +26 -0
- package/src/_services/api/schema.d.ts +2527 -0
- package/src/_services/auth/index.ts +127 -0
- package/src/_utils/advanced-filter.ts +386 -0
- package/src/_utils/calendar.ts +103 -0
- package/src/_utils/crop-image.ts +65 -0
- package/src/_utils/filter.ts +313 -0
- package/src/_utils/format.ts +35 -0
- package/src/_utils/initials.ts +24 -0
- package/src/_utils/password.ts +7 -0
- package/src/components/app-shell.tsx +314 -0
- package/src/components/breadcrumbs.tsx +55 -0
- package/src/components/index.ts +7 -0
- package/src/components/language-selector.tsx +51 -0
- package/src/components/password-field.tsx +40 -0
- package/src/components/theme-toggle.tsx +27 -0
- package/src/components/ui/alert.tsx +27 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +86 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/column-visibility-modal.tsx +110 -0
- package/src/components/ui/confirm-dialog.tsx +69 -0
- package/src/components/ui/data-table.tsx +401 -0
- package/src/components/ui/date-range-picker.tsx +250 -0
- package/src/components/ui/dialog.tsx +138 -0
- package/src/components/ui/dropdown-menu.tsx +108 -0
- package/src/components/ui/field.tsx +20 -0
- package/src/components/ui/filter-chips.tsx +88 -0
- package/src/components/ui/filter-field.tsx +137 -0
- package/src/components/ui/filter-group.tsx +155 -0
- package/src/components/ui/filter-rule.tsx +221 -0
- package/src/components/ui/filter-sheet.tsx +201 -0
- package/src/components/ui/index.ts +30 -0
- package/src/components/ui/input.tsx +25 -0
- package/src/components/ui/label.tsx +23 -0
- package/src/components/ui/row-actions.tsx +32 -0
- package/src/components/ui/select.tsx +97 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/sheet.tsx +119 -0
- package/src/components/ui/sonner.tsx +28 -0
- package/src/components/ui/spinner.tsx +12 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/table.tsx +84 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/components/user-avatar.tsx +54 -0
- package/src/contexts/auth-context.tsx +90 -0
- package/src/contexts/color-mode-context.tsx +105 -0
- package/src/contexts/i18n-context.tsx +76 -0
- package/src/contexts/index.ts +6 -0
- package/src/contexts/socket-context.tsx +153 -0
- package/src/contexts/toast-context.tsx +40 -0
- package/src/features/audit/audit-screen.tsx +299 -0
- package/src/features/audit/hooks/use-audit-trail.ts +68 -0
- package/src/features/audit/services/audit.service.ts +24 -0
- package/src/features/dashboard/dashboard-screen.tsx +73 -0
- package/src/features/login/components/backup-codes-dialog.tsx +66 -0
- package/src/features/login/components/index.ts +5 -0
- package/src/features/login/components/login-form.tsx +62 -0
- package/src/features/login/components/two-factor-setup.tsx +71 -0
- package/src/features/login/components/two-factor-verify.tsx +56 -0
- package/src/features/login/hooks/use-login-flow.ts +119 -0
- package/src/features/login/login-screen.tsx +83 -0
- package/src/features/login/validation/schemas.ts +30 -0
- package/src/features/logs/hooks/use-error-logs.ts +65 -0
- package/src/features/logs/hooks/use-request-logs.ts +65 -0
- package/src/features/logs/logs-screen.tsx +31 -0
- package/src/features/logs/panels/error-logs-panel.tsx +183 -0
- package/src/features/logs/panels/index.ts +3 -0
- package/src/features/logs/panels/request-logs-panel.tsx +124 -0
- package/src/features/logs/services/logs.service.ts +68 -0
- package/src/features/notifications/components/notification-item.tsx +67 -0
- package/src/features/notifications/hooks/use-notifications.ts +78 -0
- package/src/features/notifications/notifications-center.tsx +119 -0
- package/src/features/notifications/services/notifications.service.ts +38 -0
- package/src/features/profile/components/avatar-crop-dialog.tsx +133 -0
- package/src/features/profile/components/index.ts +2 -0
- package/src/features/profile/force-password-change.tsx +114 -0
- package/src/features/profile/profile-screen.tsx +385 -0
- package/src/features/rbac/panels/actions-panel.tsx +20 -0
- package/src/features/rbac/panels/catalog-panel.tsx +243 -0
- package/src/features/rbac/panels/groups-panel.tsx +269 -0
- package/src/features/rbac/panels/index.ts +8 -0
- package/src/features/rbac/panels/permissions-panel.tsx +204 -0
- package/src/features/rbac/panels/resources-panel.tsx +20 -0
- package/src/features/rbac/panels/roles-panel.tsx +224 -0
- package/src/features/rbac/panels/scopes-panel.tsx +20 -0
- package/src/features/rbac/rbac-screen.tsx +80 -0
- package/src/features/rbac/services/rbac.service.ts +110 -0
- package/src/features/recovery/forgot-password-screen.tsx +69 -0
- package/src/features/recovery/reset-password-screen.tsx +163 -0
- package/src/features/users/components/user-form-dialog.tsx +187 -0
- package/src/features/users/hooks/use-users.ts +181 -0
- package/src/features/users/services/users.service.ts +83 -0
- package/src/features/users/users-screen.tsx +331 -0
- package/src/hooks/use-column-visibility.ts +54 -0
- package/src/hooks/use-confirm.tsx +70 -0
- package/src/hooks/use-filter-schema.ts +85 -0
- package/src/hooks/use-filters.ts +148 -0
- package/src/hooks/use-list-query.ts +69 -0
- package/src/hooks/use-request.ts +114 -0
- package/src/i18n/messages/en.ts +296 -0
- package/src/i18n/messages/pt.ts +305 -0
- package/src/index.ts +60 -0
- package/src/lib/utils.ts +6 -0
- package/src/middleware.ts +42 -0
- package/src/styles/core.css +142 -0
- 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
|
+
}
|