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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.14.0",
4
- "description": "Telas e componentes Next.js do core: login com 2FA, usu\u00e1rios, RBAC, auditoria, logs e listagens com filtro din\u00e2mico",
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
- const jsonToNode = (
298
- json: Record<string, unknown>,
299
- schema: FilterField[],
300
- ): AdvancedNode | null => {
301
- const entries = Object.entries(json);
302
- if (entries.length === 0) {
303
- return null;
304
- }
305
- const [key, value] = entries[0];
306
-
307
- if (key === "$AND" || key === "$OR") {
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: key,
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-72 w-(--radix-dropdown-menu-trigger-width) overflow-y-auto p-1",
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 { useEffect, useRef, useState } from "react";
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
- <div className="relative" ref={containerRef}>
100
- <button
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
- </button>
89
+ </PopoverTrigger>
114
90
 
115
- {open && (
116
- <div
117
- role="dialog"
118
- // Largura do campo, e não fixa: o painel é a continuação dele, e
119
- // solto no meio de um formulário de duas colunas ficava torto.
120
- className="absolute left-0 z-50 mt-2 w-full min-w-[17rem] rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-lg"
121
- >
122
- <div className="mb-2 flex items-center justify-between">
123
- <Button
124
- type="button"
125
- variant="ghost"
126
- size="iconSm"
127
- aria-label={t("filters.previousMonth")}
128
- onClick={() => setMonth(addMonths(month, -1))}
129
- >
130
- <ChevronLeft className="h-4 w-4" />
131
- </Button>
132
- <span className="text-sm font-medium capitalize">
133
- {monthLabel(month, locale)}
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
- <Button
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
- variant="ghost"
138
- size="iconSm"
139
- aria-label={t("filters.nextMonth")}
140
- onClick={() => setMonth(addMonths(month, 1))}
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
- <ChevronRight className="h-4 w-4" />
143
- </Button>
144
- </div>
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
- <div className="mt-2 flex justify-between border-t border-border pt-2">
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
- pickDay(today());
177
- setMonth(today());
162
+ onChange(undefined);
163
+ setOpen(false);
178
164
  }}
179
165
  >
180
- {t("filters.today")}
166
+ {t("filters.clear")}
181
167
  </Button>
182
- {value && (
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
- </div>
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 { useEffect, useRef, useState } from "react";
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
- <div className="relative" ref={containerRef}>
142
- <button
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
- </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>
131
+ </PopoverTrigger>
214
132
 
215
- <div className="grid grid-cols-7 gap-y-1 justify-items-center">
216
- {monthGrid(month).map((day) => (
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={day.toISOString()}
138
+ key={days}
219
139
  type="button"
220
- onClick={() => pickDay(day)}
140
+ onClick={() => applyPreset(days)}
221
141
  className={cn(
222
- "flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors hover:bg-accent",
223
- dayClass(day),
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
- {day.getDate()}
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
- {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
- )}
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
- </div>
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-96 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",
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
- const res = await run(() => auditService.listDataChanges(list.query));
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
- const res = await run(() => logsService.listRequests(list.query));
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
- const [list, summary] = await Promise.all([
61
- // O `page` da tabela é base zero; o da API, base um.
62
- run(() =>
63
- queuesService.list({
64
- state,
65
- page: page + 1,
66
- limit: pageSize,
67
- sortBy: sorting[0]?.id,
68
- sortDir: sorting[0] ? (sorting[0].desc ? "DESC" : "ASC") : undefined,
69
- }),
70
- ),
71
- run(() => queuesService.counts()),
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
- const res = await run(() => usersService.list(list.query));
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";