rl-core-front 0.14.0 → 0.14.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/package.json +3 -2
- package/src/_utils/advanced-filter.ts +77 -33
- package/src/components/ui/combobox.tsx +1 -1
- package/src/components/ui/date-picker.tsx +77 -104
- package/src/components/ui/date-range-picker.tsx +86 -115
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/popover.tsx +58 -0
- package/src/components/ui/select.tsx +1 -1
- package/src/features/audit/hooks/use-audit-trail.ts +5 -2
- package/src/features/logs/hooks/use-request-logs.ts +5 -2
- package/src/features/queues/hooks/use-queue-jobs.ts +28 -14
- package/src/features/users/hooks/use-users.ts +5 -2
- package/src/hooks/use-latest-request.ts +30 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rl-core-front",
|
|
3
|
-
"version": "0.14.
|
|
4
|
-
"description": "Telas e componentes Next.js do core: login com 2FA,
|
|
3
|
+
"version": "0.14.1",
|
|
4
|
+
"description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
|
|
5
5
|
"author": "Rodrigo Liberti",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"@radix-ui/react-dialog": "^1.1.23",
|
|
67
67
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
|
68
68
|
"@radix-ui/react-label": "^2.1.15",
|
|
69
|
+
"@radix-ui/react-popover": "^1.1.23",
|
|
69
70
|
"@radix-ui/react-select": "^2.3.7",
|
|
70
71
|
"@radix-ui/react-separator": "^1.1.15",
|
|
71
72
|
"@radix-ui/react-slot": "^1.3.3",
|
|
@@ -294,45 +294,24 @@ const asText = (field: FilterField, value: unknown): string => {
|
|
|
294
294
|
: ((value as string | null) ?? "");
|
|
295
295
|
};
|
|
296
296
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (
|
|
308
|
-
if (!Array.isArray(value)) {
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
const children = value
|
|
312
|
-
.map((child) =>
|
|
313
|
-
typeof child === "object" && child !== null
|
|
314
|
-
? jsonToNode(child as Record<string, unknown>, schema)
|
|
315
|
-
: null,
|
|
316
|
-
)
|
|
317
|
-
.filter((child): child is AdvancedNode => child !== null);
|
|
318
|
-
return { id: uid(), kind: "group", combinator: key, children };
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const field = schema.find((item) => item.field === key);
|
|
322
|
-
if (!field || typeof value !== "object" || value === null) {
|
|
323
|
-
return null;
|
|
324
|
-
}
|
|
325
|
-
const [operator, operand] = Object.entries(
|
|
326
|
-
value as Record<string, unknown>,
|
|
327
|
-
)[0] ?? [null, null];
|
|
328
|
-
if (!operator || !OPERATOR_SET.has(operator)) {
|
|
297
|
+
/** Objeto simples — o que o DSL usa para o mapa de operadores de um campo. */
|
|
298
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
299
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
300
|
+
|
|
301
|
+
const toRule = (
|
|
302
|
+
field: FilterField,
|
|
303
|
+
fieldName: string,
|
|
304
|
+
operator: string,
|
|
305
|
+
operand: unknown,
|
|
306
|
+
): AdvancedRule | null => {
|
|
307
|
+
if (!OPERATOR_SET.has(operator)) {
|
|
329
308
|
return null;
|
|
330
309
|
}
|
|
331
310
|
|
|
332
311
|
const rule: AdvancedRule = {
|
|
333
312
|
id: uid(),
|
|
334
313
|
kind: "rule",
|
|
335
|
-
field:
|
|
314
|
+
field: fieldName,
|
|
336
315
|
operator: operator as FilterOperator,
|
|
337
316
|
value: "",
|
|
338
317
|
};
|
|
@@ -352,6 +331,71 @@ const jsonToNode = (
|
|
|
352
331
|
return rule;
|
|
353
332
|
};
|
|
354
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Uma entrada do objeto vira zero, uma ou várias regras.
|
|
336
|
+
*
|
|
337
|
+
* Várias porque o mesmo campo aceita mais de um operador —
|
|
338
|
+
* `{"age": {"$GTE": 18, "$LTE": 30}}` são duas condições unidas por `E`, e é
|
|
339
|
+
* assim que o backend as lê. Zero quando o campo não está no catálogo: a URL
|
|
340
|
+
* é editável à mão, e um campo inventado não pode derrubar a tela.
|
|
341
|
+
*/
|
|
342
|
+
const entryToNodes = (
|
|
343
|
+
key: string,
|
|
344
|
+
value: unknown,
|
|
345
|
+
schema: FilterField[],
|
|
346
|
+
): AdvancedNode[] => {
|
|
347
|
+
if (key === "$AND" || key === "$OR") {
|
|
348
|
+
if (!Array.isArray(value)) {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
const children = value
|
|
352
|
+
.map((child) =>
|
|
353
|
+
isPlainObject(child) ? jsonToNode(child, schema) : null,
|
|
354
|
+
)
|
|
355
|
+
.filter((child): child is AdvancedNode => child !== null);
|
|
356
|
+
return [{ id: uid(), kind: "group", combinator: key, children }];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const field = schema.find((item) => item.field === key);
|
|
360
|
+
if (!field) {
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Açúcar do DSL: valor cru equivale a $EQ — {"name": "ana"}.
|
|
365
|
+
const operators = isPlainObject(value)
|
|
366
|
+
? value
|
|
367
|
+
: ({ $EQ: value } as Record<string, unknown>);
|
|
368
|
+
|
|
369
|
+
return Object.entries(operators)
|
|
370
|
+
.map(([operator, operand]) => toRule(field, key, operator, operand))
|
|
371
|
+
.filter((rule): rule is AdvancedRule => rule !== null);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Lê **todas** as entradas do objeto, não só a primeira.
|
|
376
|
+
*
|
|
377
|
+
* `{"name": …, "isActive": …}` são duas condições unidas por `E` — é como o
|
|
378
|
+
* backend as aplica. Ler só `entries[0]` fazia a tela reabrir com um filtro
|
|
379
|
+
* mais frouxo do que o da URL, e a primeira edição regravava a URL já sem as
|
|
380
|
+
* irmãs: o link deixava de valer sem ninguém pedir.
|
|
381
|
+
*/
|
|
382
|
+
const jsonToNode = (
|
|
383
|
+
json: Record<string, unknown>,
|
|
384
|
+
schema: FilterField[],
|
|
385
|
+
): AdvancedNode | null => {
|
|
386
|
+
const nodes = Object.entries(json).flatMap(([key, value]) =>
|
|
387
|
+
entryToNodes(key, value, schema),
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
if (nodes.length === 0) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
if (nodes.length === 1) {
|
|
394
|
+
return nodes[0];
|
|
395
|
+
}
|
|
396
|
+
return { id: uid(), kind: "group", combinator: "$AND", children: nodes };
|
|
397
|
+
};
|
|
398
|
+
|
|
355
399
|
/**
|
|
356
400
|
* JSON → árvore, para reabrir a tela no mesmo filtro.
|
|
357
401
|
*
|
|
@@ -212,7 +212,7 @@ export function Combobox({
|
|
|
212
212
|
* e o painel continua aberto para marcar o próximo.
|
|
213
213
|
*/
|
|
214
214
|
className={cn(
|
|
215
|
-
"max-h-
|
|
215
|
+
"max-h-[min(18rem,var(--radix-dropdown-menu-content-available-height))] w-(--radix-dropdown-menu-trigger-width) overflow-y-auto p-1",
|
|
216
216
|
!multiple &&
|
|
217
217
|
"data-[side=bottom]:-mt-(--radix-dropdown-menu-trigger-height) data-[side=top]:-mb-(--radix-dropdown-menu-trigger-height)",
|
|
218
218
|
)}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Calendar, ChevronLeft, ChevronRight } from "lucide-react";
|
|
4
4
|
import type { JSX } from "react";
|
|
5
|
-
import {
|
|
5
|
+
import { useState } from "react";
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
addMonths,
|
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
weekdayLabels,
|
|
18
18
|
} from "#core/_utils/calendar";
|
|
19
19
|
import { Button } from "#core/components/ui/button";
|
|
20
|
+
import {
|
|
21
|
+
Popover,
|
|
22
|
+
PopoverContent,
|
|
23
|
+
PopoverTrigger,
|
|
24
|
+
} from "#core/components/ui/popover";
|
|
20
25
|
import { useI18n } from "#core/contexts";
|
|
21
26
|
import { cn } from "#core/lib/utils";
|
|
22
27
|
|
|
@@ -50,36 +55,11 @@ export function DatePicker({
|
|
|
50
55
|
placeholder,
|
|
51
56
|
}: DatePickerProps): JSX.Element {
|
|
52
57
|
const { t, locale } = useI18n();
|
|
53
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
54
58
|
const [open, setOpen] = useState(false);
|
|
55
59
|
|
|
56
60
|
const selected = fromIsoDay(value);
|
|
57
61
|
const [month, setMonth] = useState<Date>(() => selected ?? today());
|
|
58
62
|
|
|
59
|
-
// Fecha ao clicar fora e no ESC — o painel é flutuante e não tem overlay
|
|
60
|
-
// próprio, então quem fecha é o documento.
|
|
61
|
-
useEffect(() => {
|
|
62
|
-
if (!open) {
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
const onPointerDown = (event: MouseEvent): void => {
|
|
66
|
-
if (!containerRef.current?.contains(event.target as Node)) {
|
|
67
|
-
setOpen(false);
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
const onKeyDown = (event: KeyboardEvent): void => {
|
|
71
|
-
if (event.key === "Escape") {
|
|
72
|
-
setOpen(false);
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
document.addEventListener("mousedown", onPointerDown);
|
|
76
|
-
document.addEventListener("keydown", onKeyDown);
|
|
77
|
-
return () => {
|
|
78
|
-
document.removeEventListener("mousedown", onPointerDown);
|
|
79
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
80
|
-
};
|
|
81
|
-
}, [open]);
|
|
82
|
-
|
|
83
63
|
const pickDay = (day: Date): void => {
|
|
84
64
|
onChange(toIsoDay(day));
|
|
85
65
|
setOpen(false);
|
|
@@ -96,105 +76,98 @@ export function DatePicker({
|
|
|
96
76
|
};
|
|
97
77
|
|
|
98
78
|
return (
|
|
99
|
-
<
|
|
100
|
-
<
|
|
101
|
-
type="button"
|
|
79
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
80
|
+
<PopoverTrigger
|
|
102
81
|
disabled={disabled}
|
|
103
|
-
aria-haspopup="dialog"
|
|
104
|
-
aria-expanded={open}
|
|
105
82
|
aria-label={label}
|
|
106
|
-
onClick={() => setOpen((current) => !current)}
|
|
107
83
|
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"
|
|
108
84
|
>
|
|
109
85
|
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
|
110
86
|
{value ? formatDay(value, locale) : (placeholder ?? t("filters.selectDate"))}
|
|
111
87
|
</span>
|
|
112
88
|
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
113
|
-
</
|
|
89
|
+
</PopoverTrigger>
|
|
114
90
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
91
|
+
<PopoverContent
|
|
92
|
+
// Largura do campo, e não fixa: o painel é a continuação dele, e
|
|
93
|
+
// solto no meio de um formulário de duas colunas ficava torto.
|
|
94
|
+
className="w-(--radix-popover-trigger-width) min-w-[17rem]"
|
|
95
|
+
>
|
|
96
|
+
<div className="mb-2 flex items-center justify-between">
|
|
97
|
+
<Button
|
|
98
|
+
type="button"
|
|
99
|
+
variant="ghost"
|
|
100
|
+
size="iconSm"
|
|
101
|
+
aria-label={t("filters.previousMonth")}
|
|
102
|
+
onClick={() => setMonth(addMonths(month, -1))}
|
|
103
|
+
>
|
|
104
|
+
<ChevronLeft className="h-4 w-4" />
|
|
105
|
+
</Button>
|
|
106
|
+
<span className="text-sm font-medium capitalize">
|
|
107
|
+
{monthLabel(month, locale)}
|
|
108
|
+
</span>
|
|
109
|
+
<Button
|
|
110
|
+
type="button"
|
|
111
|
+
variant="ghost"
|
|
112
|
+
size="iconSm"
|
|
113
|
+
aria-label={t("filters.nextMonth")}
|
|
114
|
+
onClick={() => setMonth(addMonths(month, 1))}
|
|
115
|
+
>
|
|
116
|
+
<ChevronRight className="h-4 w-4" />
|
|
117
|
+
</Button>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className="mb-1 grid grid-cols-7 text-center text-xs text-muted-foreground">
|
|
121
|
+
{weekdayLabels(locale).map((weekday) => (
|
|
122
|
+
<span key={weekday} className="capitalize">
|
|
123
|
+
{weekday}
|
|
134
124
|
</span>
|
|
135
|
-
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="grid grid-cols-7 gap-y-1 justify-items-center">
|
|
129
|
+
{monthGrid(month).map((day) => (
|
|
130
|
+
<button
|
|
131
|
+
key={day.toISOString()}
|
|
136
132
|
type="button"
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
133
|
+
onClick={() => pickDay(day)}
|
|
134
|
+
className={cn(
|
|
135
|
+
"flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors hover:bg-accent",
|
|
136
|
+
dayClass(day),
|
|
137
|
+
)}
|
|
141
138
|
>
|
|
142
|
-
|
|
143
|
-
</
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<div className="mb-1 grid grid-cols-7 text-center text-xs text-muted-foreground">
|
|
147
|
-
{weekdayLabels(locale).map((weekday) => (
|
|
148
|
-
<span key={weekday} className="capitalize">
|
|
149
|
-
{weekday}
|
|
150
|
-
</span>
|
|
151
|
-
))}
|
|
152
|
-
</div>
|
|
153
|
-
|
|
154
|
-
<div className="grid grid-cols-7 gap-y-1 justify-items-center">
|
|
155
|
-
{monthGrid(month).map((day) => (
|
|
156
|
-
<button
|
|
157
|
-
key={day.toISOString()}
|
|
158
|
-
type="button"
|
|
159
|
-
onClick={() => pickDay(day)}
|
|
160
|
-
className={cn(
|
|
161
|
-
"flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors hover:bg-accent",
|
|
162
|
-
dayClass(day),
|
|
163
|
-
)}
|
|
164
|
-
>
|
|
165
|
-
{day.getDate()}
|
|
166
|
-
</button>
|
|
167
|
-
))}
|
|
168
|
-
</div>
|
|
139
|
+
{day.getDate()}
|
|
140
|
+
</button>
|
|
141
|
+
))}
|
|
142
|
+
</div>
|
|
169
143
|
|
|
170
|
-
|
|
144
|
+
<div className="mt-2 flex justify-between border-t border-border pt-2">
|
|
145
|
+
<Button
|
|
146
|
+
type="button"
|
|
147
|
+
variant="ghost"
|
|
148
|
+
size="sm"
|
|
149
|
+
onClick={() => {
|
|
150
|
+
pickDay(today());
|
|
151
|
+
setMonth(today());
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
{t("filters.today")}
|
|
155
|
+
</Button>
|
|
156
|
+
{value && (
|
|
171
157
|
<Button
|
|
172
158
|
type="button"
|
|
173
159
|
variant="ghost"
|
|
174
160
|
size="sm"
|
|
175
161
|
onClick={() => {
|
|
176
|
-
|
|
177
|
-
|
|
162
|
+
onChange(undefined);
|
|
163
|
+
setOpen(false);
|
|
178
164
|
}}
|
|
179
165
|
>
|
|
180
|
-
{t("filters.
|
|
166
|
+
{t("filters.clear")}
|
|
181
167
|
</Button>
|
|
182
|
-
|
|
183
|
-
<Button
|
|
184
|
-
type="button"
|
|
185
|
-
variant="ghost"
|
|
186
|
-
size="sm"
|
|
187
|
-
onClick={() => {
|
|
188
|
-
onChange(undefined);
|
|
189
|
-
setOpen(false);
|
|
190
|
-
}}
|
|
191
|
-
>
|
|
192
|
-
{t("filters.clear")}
|
|
193
|
-
</Button>
|
|
194
|
-
)}
|
|
195
|
-
</div>
|
|
168
|
+
)}
|
|
196
169
|
</div>
|
|
197
|
-
|
|
198
|
-
</
|
|
170
|
+
</PopoverContent>
|
|
171
|
+
</Popover>
|
|
199
172
|
);
|
|
200
173
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Calendar, Check, ChevronLeft, ChevronRight } from "lucide-react";
|
|
4
4
|
import type { JSX } from "react";
|
|
5
|
-
import {
|
|
5
|
+
import { useState } from "react";
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
addDays,
|
|
@@ -19,6 +19,11 @@ import {
|
|
|
19
19
|
weekdayLabels,
|
|
20
20
|
} from "#core/_utils/calendar";
|
|
21
21
|
import { Button } from "#core/components/ui/button";
|
|
22
|
+
import {
|
|
23
|
+
Popover,
|
|
24
|
+
PopoverContent,
|
|
25
|
+
PopoverTrigger,
|
|
26
|
+
} from "#core/components/ui/popover";
|
|
22
27
|
import { useI18n } from "#core/contexts";
|
|
23
28
|
import { cn } from "#core/lib/utils";
|
|
24
29
|
|
|
@@ -56,37 +61,12 @@ export function DateRangePicker({
|
|
|
56
61
|
label,
|
|
57
62
|
}: DateRangePickerProps): JSX.Element {
|
|
58
63
|
const { t, locale } = useI18n();
|
|
59
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
60
64
|
const [open, setOpen] = useState(false);
|
|
61
65
|
|
|
62
66
|
const from = fromIsoDay(value.from);
|
|
63
67
|
const to = fromIsoDay(value.to);
|
|
64
68
|
const [month, setMonth] = useState<Date>(() => from ?? to ?? today());
|
|
65
69
|
|
|
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
70
|
const pickDay = (day: Date): void => {
|
|
91
71
|
const iso = toIsoDay(day);
|
|
92
72
|
// Intervalo completo (ou vazio) recomeça a seleção; com só o início, o
|
|
@@ -138,113 +118,104 @@ export function DateRangePicker({
|
|
|
138
118
|
const hasValue = Boolean(value.from || value.to);
|
|
139
119
|
|
|
140
120
|
return (
|
|
141
|
-
<
|
|
142
|
-
<
|
|
143
|
-
type="button"
|
|
121
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
122
|
+
<PopoverTrigger
|
|
144
123
|
disabled={disabled}
|
|
145
|
-
aria-haspopup="dialog"
|
|
146
|
-
aria-expanded={open}
|
|
147
124
|
aria-label={label}
|
|
148
|
-
onClick={() => setOpen((current) => !current)}
|
|
149
125
|
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
126
|
>
|
|
151
127
|
<span className={cn("truncate", !hasValue && "text-muted-foreground")}>
|
|
152
128
|
{summary()}
|
|
153
129
|
</span>
|
|
154
130
|
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
155
|
-
</
|
|
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>
|
|
131
|
+
</PopoverTrigger>
|
|
214
132
|
|
|
215
|
-
|
|
216
|
-
|
|
133
|
+
<PopoverContent className="w-[19rem]">
|
|
134
|
+
{presets.length > 0 && (
|
|
135
|
+
<div className="mb-3 flex flex-wrap gap-1.5">
|
|
136
|
+
{presets.map((days) => (
|
|
217
137
|
<button
|
|
218
|
-
key={
|
|
138
|
+
key={days}
|
|
219
139
|
type="button"
|
|
220
|
-
onClick={() =>
|
|
140
|
+
onClick={() => applyPreset(days)}
|
|
221
141
|
className={cn(
|
|
222
|
-
"flex
|
|
223
|
-
|
|
142
|
+
"flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors",
|
|
143
|
+
isPresetActive(days)
|
|
144
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
145
|
+
: "border-border hover:border-ring",
|
|
224
146
|
)}
|
|
225
147
|
>
|
|
226
|
-
{
|
|
148
|
+
{isPresetActive(days) && <Check className="h-3 w-3" />}
|
|
149
|
+
{t("filters.lastDays", { days })}
|
|
227
150
|
</button>
|
|
228
151
|
))}
|
|
229
152
|
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<div className="mb-2 flex items-center justify-between">
|
|
156
|
+
<Button
|
|
157
|
+
type="button"
|
|
158
|
+
variant="ghost"
|
|
159
|
+
size="iconSm"
|
|
160
|
+
aria-label={t("filters.previousMonth")}
|
|
161
|
+
onClick={() => setMonth(addMonths(month, -1))}
|
|
162
|
+
>
|
|
163
|
+
<ChevronLeft className="h-4 w-4" />
|
|
164
|
+
</Button>
|
|
165
|
+
<span className="text-sm font-medium capitalize">
|
|
166
|
+
{monthLabel(month, locale)}
|
|
167
|
+
</span>
|
|
168
|
+
<Button
|
|
169
|
+
type="button"
|
|
170
|
+
variant="ghost"
|
|
171
|
+
size="iconSm"
|
|
172
|
+
aria-label={t("filters.nextMonth")}
|
|
173
|
+
onClick={() => setMonth(addMonths(month, 1))}
|
|
174
|
+
>
|
|
175
|
+
<ChevronRight className="h-4 w-4" />
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
230
178
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
onClick={() => {
|
|
238
|
-
onChange({});
|
|
239
|
-
setOpen(false);
|
|
240
|
-
}}
|
|
241
|
-
>
|
|
242
|
-
{t("filters.clear")}
|
|
243
|
-
</Button>
|
|
244
|
-
</div>
|
|
245
|
-
)}
|
|
179
|
+
<div className="mb-1 grid grid-cols-7 text-center text-xs text-muted-foreground">
|
|
180
|
+
{weekdayLabels(locale).map((weekday) => (
|
|
181
|
+
<span key={weekday} className="capitalize">
|
|
182
|
+
{weekday}
|
|
183
|
+
</span>
|
|
184
|
+
))}
|
|
246
185
|
</div>
|
|
247
|
-
|
|
248
|
-
|
|
186
|
+
|
|
187
|
+
<div className="grid grid-cols-7 gap-y-1 justify-items-center">
|
|
188
|
+
{monthGrid(month).map((day) => (
|
|
189
|
+
<button
|
|
190
|
+
key={day.toISOString()}
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={() => pickDay(day)}
|
|
193
|
+
className={cn(
|
|
194
|
+
"flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors hover:bg-accent",
|
|
195
|
+
dayClass(day),
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
198
|
+
{day.getDate()}
|
|
199
|
+
</button>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{hasValue && (
|
|
204
|
+
<div className="mt-2 flex justify-end border-t border-border pt-2">
|
|
205
|
+
<Button
|
|
206
|
+
type="button"
|
|
207
|
+
variant="ghost"
|
|
208
|
+
size="sm"
|
|
209
|
+
onClick={() => {
|
|
210
|
+
onChange({});
|
|
211
|
+
setOpen(false);
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{t("filters.clear")}
|
|
215
|
+
</Button>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</PopoverContent>
|
|
219
|
+
</Popover>
|
|
249
220
|
);
|
|
250
221
|
}
|
|
@@ -22,6 +22,7 @@ export * from "./input";
|
|
|
22
22
|
export * from "./job-progress";
|
|
23
23
|
export * from "./label";
|
|
24
24
|
export * from "./password-requirements";
|
|
25
|
+
export * from "./popover";
|
|
25
26
|
export * from "./row-actions";
|
|
26
27
|
export * from "./segmented-control";
|
|
27
28
|
export * from "./select";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "#core/lib/utils";
|
|
7
|
+
|
|
8
|
+
const Popover = PopoverPrimitive.Root;
|
|
9
|
+
const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
10
|
+
const PopoverAnchor = PopoverPrimitive.Anchor;
|
|
11
|
+
|
|
12
|
+
/** Folga entre o gatilho e o painel — a mesma do `DropdownMenu`. */
|
|
13
|
+
const DEFAULT_SIDE_OFFSET = 4;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Painel flutuante ancorado num gatilho — a casca de calendário, seletor de
|
|
17
|
+
* dia e afins.
|
|
18
|
+
*
|
|
19
|
+
* O `Portal` é a razão de este arquivo existir. Painel posicionado com
|
|
20
|
+
* `absolute` dentro do próprio formulário vive **dentro** da área rolável do
|
|
21
|
+
* modal (`DialogForm`), e ali ele: cresce o `scrollHeight`, faz aparecer barra
|
|
22
|
+
* horizontal, e é cortado pela borda do modal. Saindo para o `body`, o painel
|
|
23
|
+
* passa por cima do modal — que é o desenho certo — e o modal não sente nada.
|
|
24
|
+
*
|
|
25
|
+
* O resto vem de graça do Radix e some daqui: fechar no Esc e no clique fora
|
|
26
|
+
* (respeitando a pilha de camadas, então o Esc fecha o painel **sem** fechar o
|
|
27
|
+
* modal atrás), virar para cima quando não cabe embaixo e devolver o foco ao
|
|
28
|
+
* gatilho.
|
|
29
|
+
*/
|
|
30
|
+
const PopoverContent = React.forwardRef<
|
|
31
|
+
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
32
|
+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
|
33
|
+
>(
|
|
34
|
+
(
|
|
35
|
+
{ className, align = "start", sideOffset = DEFAULT_SIDE_OFFSET, ...props },
|
|
36
|
+
ref,
|
|
37
|
+
) => (
|
|
38
|
+
<PopoverPrimitive.Portal>
|
|
39
|
+
<PopoverPrimitive.Content
|
|
40
|
+
ref={ref}
|
|
41
|
+
align={align}
|
|
42
|
+
sideOffset={sideOffset}
|
|
43
|
+
// `collisionPadding` afasta o painel da borda da janela antes de ele
|
|
44
|
+
// encostar: sem folga, o calendário aberto num campo do rodapé fica
|
|
45
|
+
// com a última linha rente ao fim da tela.
|
|
46
|
+
collisionPadding={8}
|
|
47
|
+
className={cn(
|
|
48
|
+
"z-50 max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
</PopoverPrimitive.Portal>
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
|
57
|
+
|
|
58
|
+
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
|
|
@@ -38,7 +38,7 @@ const SelectContent = React.forwardRef<
|
|
|
38
38
|
<SelectPrimitive.Content
|
|
39
39
|
ref={ref}
|
|
40
40
|
className={cn(
|
|
41
|
-
"relative z-50 max-h-
|
|
41
|
+
"relative z-50 max-h-[min(24rem,var(--radix-select-content-available-height))] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
42
42
|
position === "popper" && "data-[side=bottom]:translate-y-1",
|
|
43
43
|
className,
|
|
44
44
|
)}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
auditService,
|
|
15
15
|
} from "#core/features/audit/services/audit.service";
|
|
16
16
|
import type { FiltersState } from "#core/hooks/use-filters";
|
|
17
|
+
import { useLatestRequest } from "#core/hooks/use-latest-request";
|
|
17
18
|
import { useListQuery } from "#core/hooks/use-list-query";
|
|
18
19
|
import { useRequest } from "#core/hooks/use-request";
|
|
19
20
|
|
|
@@ -35,18 +36,20 @@ export interface UseAuditTrailResult {
|
|
|
35
36
|
export function useAuditTrail(): UseAuditTrailResult {
|
|
36
37
|
const { run, loading } = useRequest();
|
|
37
38
|
|
|
39
|
+
const latest = useLatestRequest();
|
|
38
40
|
const list = useListQuery("/audit/data-changes/filter-schema", 20);
|
|
39
41
|
|
|
40
42
|
const [rows, setRows] = useState<AuditDataChange[]>([]);
|
|
41
43
|
const [total, setTotal] = useState(0);
|
|
42
44
|
|
|
43
45
|
const fetch = useCallback(async () => {
|
|
44
|
-
|
|
46
|
+
// `latest` descarta a resposta que outra busca já passou para trás.
|
|
47
|
+
const res = await latest(() => run(() => auditService.listDataChanges(list.query)));
|
|
45
48
|
if (res) {
|
|
46
49
|
setRows(res.items);
|
|
47
50
|
setTotal(res.meta.total);
|
|
48
51
|
}
|
|
49
|
-
}, [run, list.query]);
|
|
52
|
+
}, [latest, run, list.query]);
|
|
50
53
|
|
|
51
54
|
// Não busca antes da URL ser lida: a query sairia sem o filtro, e essa
|
|
52
55
|
// resposta pode chegar depois da certa e pintar a tela com tudo.
|
|
@@ -13,6 +13,7 @@ import type { FilterValues } from "#core/_utils/filter";
|
|
|
13
13
|
import { RequestOutcome } from "#core/features/logs/enums/request-outcome.enum";
|
|
14
14
|
import { logsService, RequestLog } from "#core/features/logs/services/logs.service";
|
|
15
15
|
import type { FiltersState } from "#core/hooks/use-filters";
|
|
16
|
+
import { useLatestRequest } from "#core/hooks/use-latest-request";
|
|
16
17
|
import { useListQuery } from "#core/hooks/use-list-query";
|
|
17
18
|
import { useRequest } from "#core/hooks/use-request";
|
|
18
19
|
|
|
@@ -44,6 +45,7 @@ export interface UseRequestLogsResult {
|
|
|
44
45
|
export function useRequestLogs(): UseRequestLogsResult {
|
|
45
46
|
const { run, loading } = useRequest();
|
|
46
47
|
|
|
48
|
+
const latest = useLatestRequest();
|
|
47
49
|
const list = useListQuery(
|
|
48
50
|
"/audit/requests/filter-schema",
|
|
49
51
|
20,
|
|
@@ -54,12 +56,13 @@ export function useRequestLogs(): UseRequestLogsResult {
|
|
|
54
56
|
const [total, setTotal] = useState(0);
|
|
55
57
|
|
|
56
58
|
const fetch = useCallback(async () => {
|
|
57
|
-
|
|
59
|
+
// `latest` descarta a resposta que outra busca já passou para trás.
|
|
60
|
+
const res = await latest(() => run(() => logsService.listRequests(list.query)));
|
|
58
61
|
if (res) {
|
|
59
62
|
setRows(res.items);
|
|
60
63
|
setTotal(res.meta.total);
|
|
61
64
|
}
|
|
62
|
-
}, [run, list.query]);
|
|
65
|
+
}, [latest, run, list.query]);
|
|
63
66
|
|
|
64
67
|
// Não busca antes da URL ser lida: a query sairia sem o filtro, e essa
|
|
65
68
|
// resposta pode chegar depois da certa e pintar a tela com tudo.
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
QueueJobState,
|
|
10
10
|
queuesService,
|
|
11
11
|
} from "#core/features/queues/services/queues.service";
|
|
12
|
+
import { useLatestRequest } from "#core/hooks/use-latest-request";
|
|
12
13
|
import { useRequest } from "#core/hooks/use-request";
|
|
13
14
|
|
|
14
15
|
const EMPTY_COUNTS: QueueCounts = {
|
|
@@ -45,6 +46,7 @@ export interface UseQueueJobsResult {
|
|
|
45
46
|
*/
|
|
46
47
|
export function useQueueJobs(): UseQueueJobsResult {
|
|
47
48
|
const { run, loading } = useRequest();
|
|
49
|
+
const latest = useLatestRequest();
|
|
48
50
|
|
|
49
51
|
const [rows, setRows] = useState<QueueJob[]>([]);
|
|
50
52
|
const [counts, setCounts] = useState<QueueCounts>(EMPTY_COUNTS);
|
|
@@ -57,19 +59,31 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
57
59
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
58
60
|
|
|
59
61
|
const fetch = useCallback(async (): Promise<void> => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
// `latest` descarta a resposta que outra busca já passou para trás —
|
|
63
|
+
// trocar de estado e de página em sequência devolve as duas fora de ordem.
|
|
64
|
+
const result = await latest(() =>
|
|
65
|
+
Promise.all([
|
|
66
|
+
// O `page` da tabela é base zero; o da API, base um.
|
|
67
|
+
run(() =>
|
|
68
|
+
queuesService.list({
|
|
69
|
+
state,
|
|
70
|
+
page: page + 1,
|
|
71
|
+
limit: pageSize,
|
|
72
|
+
sortBy: sorting[0]?.id,
|
|
73
|
+
sortDir: sorting[0]
|
|
74
|
+
? sorting[0].desc
|
|
75
|
+
? "DESC"
|
|
76
|
+
: "ASC"
|
|
77
|
+
: undefined,
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
run(() => queuesService.counts()),
|
|
81
|
+
]),
|
|
82
|
+
);
|
|
83
|
+
if (!result) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const [list, summary] = result;
|
|
73
87
|
if (list) {
|
|
74
88
|
setRows(list.items);
|
|
75
89
|
setTotal(list.meta.total);
|
|
@@ -77,7 +91,7 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
77
91
|
if (summary) {
|
|
78
92
|
setCounts(summary);
|
|
79
93
|
}
|
|
80
|
-
}, [run, state, page, pageSize, sorting]);
|
|
94
|
+
}, [latest, run, state, page, pageSize, sorting]);
|
|
81
95
|
|
|
82
96
|
useEffect(() => {
|
|
83
97
|
void fetch();
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
usersService,
|
|
20
20
|
} from "#core/features/users/services/users.service";
|
|
21
21
|
import type { FiltersState } from "#core/hooks/use-filters";
|
|
22
|
+
import { useLatestRequest } from "#core/hooks/use-latest-request";
|
|
22
23
|
import { useListQuery } from "#core/hooks/use-list-query";
|
|
23
24
|
import { RequestOperation, useRequest } from "#core/hooks/use-request";
|
|
24
25
|
|
|
@@ -58,6 +59,7 @@ export function useUsers(): UseUsersResult {
|
|
|
58
59
|
const { t } = useI18n();
|
|
59
60
|
const { hasPermission } = useAuth();
|
|
60
61
|
|
|
62
|
+
const latest = useLatestRequest();
|
|
61
63
|
const list = useListQuery("/users/filter-schema");
|
|
62
64
|
|
|
63
65
|
const [rows, setRows] = useState<User[]>([]);
|
|
@@ -66,12 +68,13 @@ export function useUsers(): UseUsersResult {
|
|
|
66
68
|
const [total, setTotal] = useState(0);
|
|
67
69
|
|
|
68
70
|
const fetch = useCallback(async () => {
|
|
69
|
-
|
|
71
|
+
// `latest` descarta a resposta que outra busca já passou para trás.
|
|
72
|
+
const res = await latest(() => run(() => usersService.list(list.query)));
|
|
70
73
|
if (res) {
|
|
71
74
|
setRows(res.items);
|
|
72
75
|
setTotal(res.meta.total);
|
|
73
76
|
}
|
|
74
|
-
}, [run, list.query]);
|
|
77
|
+
}, [latest, run, list.query]);
|
|
75
78
|
|
|
76
79
|
// Não busca antes da URL ser lida: a query sairia sem o filtro, e essa
|
|
77
80
|
// resposta pode chegar depois da certa e pintar a tela com tudo.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
/** Envelope que devolve `null` quando a resposta já foi passada para trás. */
|
|
6
|
+
export type LatestRequest = <T>(request: () => Promise<T>) => Promise<T | null>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Guarda contra resposta fora de ordem numa listagem.
|
|
10
|
+
*
|
|
11
|
+
* Trocar de página, ordenar ou digitar na busca dispara uma requisição por
|
|
12
|
+
* toque, e elas não voltam na ordem em que saíram — a mais lenta chega depois
|
|
13
|
+
* e pinta a tela com o resultado que o usuário já abandonou. É o mesmo perigo
|
|
14
|
+
* que a espera pelo `list.ready` já cobre na primeira busca, só que ele não
|
|
15
|
+
* acaba na hidratação: vale para toda troca de página daí em diante.
|
|
16
|
+
*
|
|
17
|
+
* Só para busca de listagem. Ação de linha (salvar, excluir) precisa do
|
|
18
|
+
* resultado dela mesma, não do mais recente.
|
|
19
|
+
*/
|
|
20
|
+
export const useLatestRequest = (): LatestRequest => {
|
|
21
|
+
const lastId = useRef(0);
|
|
22
|
+
|
|
23
|
+
return useCallback(async <T,>(request: () => Promise<T>): Promise<
|
|
24
|
+
T | null
|
|
25
|
+
> => {
|
|
26
|
+
const id = ++lastId.current;
|
|
27
|
+
const result = await request();
|
|
28
|
+
return id === lastId.current ? result : null;
|
|
29
|
+
}, []);
|
|
30
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -88,6 +88,8 @@ export {
|
|
|
88
88
|
JOB_PROGRESS_EVENT,
|
|
89
89
|
useJobProgress,
|
|
90
90
|
} from "#core/hooks/use-job-progress";
|
|
91
|
+
export type { LatestRequest } from "#core/hooks/use-latest-request";
|
|
92
|
+
export { useLatestRequest } from "#core/hooks/use-latest-request";
|
|
91
93
|
export type { UseListQueryResult } from "#core/hooks/use-list-query";
|
|
92
94
|
export { useListQuery } from "#core/hooks/use-list-query";
|
|
93
95
|
export type { RunOptions, UseRequestResult } from "#core/hooks/use-request";
|