rl-core-front 0.8.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usu\u00e1rios, RBAC, auditoria, logs e listagens com filtro din\u00e2mico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -33,3 +33,43 @@ export function maskPhoneBR(value: string): string {
33
33
  if (d.length <= 10) {return d.replace(/(\d{2})(\d{4})(\d{0,4})/, "($1) $2-$3");}
34
34
  return d.replace(/(\d{2})(\d{5})(\d{0,4})/, "($1) $2-$3");
35
35
  }
36
+
37
+ /** Teto do campo de dinheiro: 999.999.999,99 — nove dígitos e os dois centavos. */
38
+ const MAX_DIGITOS_MOEDA = 11;
39
+
40
+ /**
41
+ * Máscara de dinheiro BR enquanto digita: 123456 vira 1.234,56
42
+ *
43
+ * Os dígitos entram **pela direita**, como em caixa de supermercado: cada tecla
44
+ * empurra o valor uma casa, e a vírgula nunca precisa ser digitada.
45
+ *
46
+ * Campo de texto e não `<input type="number">`: o numérico não aceita máscara,
47
+ * mostra setas de incremento e usa ponto decimal — três coisas erradas para um
48
+ * valor em reais.
49
+ */
50
+ export function maskCurrencyBR(value: string): string {
51
+ const digitos = value.replace(/\D/g, "").slice(0, MAX_DIGITOS_MOEDA);
52
+ if (digitos === "") {
53
+ return "";
54
+ }
55
+
56
+ // `padStart` é o que faz o primeiro dígito virar centavo em vez de real.
57
+ const comCentavos = digitos.padStart(3, "0");
58
+ const inteiro = Number(comCentavos.slice(0, -2));
59
+
60
+ return `${inteiro.toLocaleString("pt-BR")},${comCentavos.slice(-2)}`;
61
+ }
62
+
63
+ /** Da máscara para o número que vai à API: `1.234,56` vira `1234.56`. */
64
+ export function parseCurrencyBR(value: string): number {
65
+ const numero = Number(value.replace(/\./g, "").replace(",", "."));
66
+ return Number.isFinite(numero) ? numero : 0;
67
+ }
68
+
69
+ /** Do número que veio da API para a máscara: `1234.5` vira `1.234,50`. */
70
+ export function formatCurrencyBR(value: number | null | undefined): string {
71
+ if (value === null || value === undefined) {
72
+ return "";
73
+ }
74
+ return maskCurrencyBR(String(Math.round(value * 100)));
75
+ }
@@ -0,0 +1,217 @@
1
+ "use client";
2
+
3
+ import { Check, ChevronDown, X } from "lucide-react";
4
+ import type { JSX } from "react";
5
+ import { useEffect, useMemo, useRef, useState } from "react";
6
+
7
+ import { Badge } from "#core/components/ui/badge";
8
+ import { Button } from "#core/components/ui/button";
9
+ import {
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuTrigger,
13
+ } from "#core/components/ui/dropdown-menu";
14
+ import { Input } from "#core/components/ui/input";
15
+ import { useI18n } from "#core/contexts";
16
+
17
+ /** O mínimo para desenhar uma opção: o que identifica e o que se lê. */
18
+ export interface ComboboxOption {
19
+ id: string;
20
+ name: string;
21
+ }
22
+
23
+ export interface ComboboxProps {
24
+ options: ComboboxOption[];
25
+ /** Sempre lista — o modo de um valor usa zero ou um item. */
26
+ value: string[];
27
+ onChange: (ids: string[]) => void;
28
+ multiple?: boolean;
29
+ placeholder?: string;
30
+ searchPlaceholder?: string;
31
+ emptyMessage?: string;
32
+ disabled?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Escolha com busca, de um valor ou de vários.
37
+ *
38
+ * O que o `Select` não faz: com trinta opções, uma lista sem campo de busca
39
+ * vira caça ao tesouro, e o `Select` do Radix só tem a busca por digitação —
40
+ * que salta para a opção e não filtra nada.
41
+ *
42
+ * Um componente para os dois modos porque a diferença é uma linha na hora de
43
+ * marcar: a busca, a lista e o painel são os mesmos. Dois componentes seriam o
44
+ * mesmo arquivo duas vezes.
45
+ *
46
+ * Só escolhe — não cria. Quem cadastra o item nem sempre é quem pode cadastrar
47
+ * na origem, e um "criar" aqui daria um botão que falha na cara de quem clicou.
48
+ */
49
+ export function Combobox({
50
+ options,
51
+ value,
52
+ onChange,
53
+ multiple = false,
54
+ placeholder,
55
+ searchPlaceholder,
56
+ emptyMessage,
57
+ disabled,
58
+ }: ComboboxProps): JSX.Element {
59
+ const { t } = useI18n();
60
+ const [open, setOpen] = useState(false);
61
+ const [search, setSearch] = useState("");
62
+ const inputRef = useRef<HTMLInputElement>(null);
63
+
64
+ const selected = useMemo(
65
+ () => options.filter((option) => value.includes(option.id)),
66
+ [options, value],
67
+ );
68
+
69
+ const termo = search.trim();
70
+ const visiveis = useMemo(
71
+ () =>
72
+ options.filter((option) =>
73
+ option.name.toLowerCase().includes(termo.toLowerCase()),
74
+ ),
75
+ [options, termo],
76
+ );
77
+
78
+ const alternar = (id: string): void => {
79
+ if (!multiple) {
80
+ onChange([id]);
81
+ setOpen(false);
82
+ setSearch("");
83
+ return;
84
+ }
85
+
86
+ onChange(
87
+ value.includes(id)
88
+ ? value.filter((atual) => atual !== id)
89
+ : [...value, id],
90
+ );
91
+ };
92
+
93
+ // O Radix leva o foco para o menu ao abrir, e isso vence o `autoFocus` do
94
+ // campo de busca — sem devolver o foco, só depois de clicar nele a digitação
95
+ // pesquisa. `onOpenAutoFocus` é prop privada do `DropdownMenu` e não dá para
96
+ // interceptar, então o foco volta no quadro seguinte, quando o controle de
97
+ // foco do Radix já agiu.
98
+ useEffect(() => {
99
+ if (!open) {
100
+ return;
101
+ }
102
+
103
+ const quadro = requestAnimationFrame(() => inputRef.current?.focus());
104
+ return () => cancelAnimationFrame(quadro);
105
+ }, [open]);
106
+
107
+ return (
108
+ <DropdownMenu open={open} onOpenChange={setOpen}>
109
+ <DropdownMenuTrigger asChild disabled={disabled}>
110
+ <Button
111
+ type="button"
112
+ variant="outline"
113
+ className="h-auto min-h-9 w-full justify-between gap-2 py-1.5 font-normal"
114
+ // Chegando por Tab, digitar abre o menu já com a letra na busca. Sem
115
+ // isto o campo só responde a Enter, Espaço ou seta, e quem vem
116
+ // digitando do campo anterior perde o que escreveu.
117
+ onKeyDown={(event) => {
118
+ if (
119
+ event.key.length !== 1 ||
120
+ event.ctrlKey ||
121
+ event.metaKey ||
122
+ event.altKey
123
+ ) {
124
+ return;
125
+ }
126
+ event.preventDefault();
127
+ setSearch(event.key);
128
+ setOpen(true);
129
+ }}
130
+ >
131
+ <span className="flex flex-1 flex-wrap items-center gap-1 text-left">
132
+ {selected.length === 0 && (
133
+ <span className="text-muted-foreground">
134
+ {placeholder ?? t("common.select")}
135
+ </span>
136
+ )}
137
+ {multiple
138
+ ? selected.map((option) => (
139
+ <Badge key={option.id} variant="secondary" className="gap-1">
140
+ {option.name}
141
+ <X
142
+ className="h-3 w-3 cursor-pointer"
143
+ onClick={(event) => {
144
+ // Sem isto, tirar um selo abriria o menu junto.
145
+ event.stopPropagation();
146
+ onChange(value.filter((id) => id !== option.id));
147
+ }}
148
+ />
149
+ </Badge>
150
+ ))
151
+ : selected.map((option) => (
152
+ <span key={option.id}>{option.name}</span>
153
+ ))}
154
+ </span>
155
+ <ChevronDown className="h-4 w-4 shrink-0 opacity-60" />
156
+ </Button>
157
+ </DropdownMenuTrigger>
158
+
159
+ <DropdownMenuContent
160
+ /*
161
+ * Cara de select: mesma largura do campo e aberto **por cima** dele.
162
+ *
163
+ * A largura precisa de `var(...)` por extenso — o atalho
164
+ * `w-[--variavel]` era do Tailwind 3 e não gera CSS na 4. A margem
165
+ * negativa é o que sobrepõe: com `sideOffset` zero o painel encosta na
166
+ * borda do campo, e subir a altura dele cobre o campo. O `data-[side]`
167
+ * cuida da abertura para cima, quando não há espaço embaixo.
168
+ */
169
+ className="max-h-72 w-(--radix-dropdown-menu-trigger-width) overflow-y-auto p-1 data-[side=bottom]:-mt-(--radix-dropdown-menu-trigger-height) data-[side=top]:-mb-(--radix-dropdown-menu-trigger-height)"
170
+ align="start"
171
+ sideOffset={0}
172
+ >
173
+ <div className="p-1">
174
+ <Input
175
+ ref={inputRef}
176
+ value={search}
177
+ placeholder={searchPlaceholder ?? t("common.search")}
178
+ onChange={(event) => setSearch(event.target.value)}
179
+ // O menu do Radix tem busca por digitação própria: sem parar o
180
+ // evento aqui, digitar "c" pularia o foco para o item "Chase" em
181
+ // vez de escrever no campo.
182
+ onKeyDown={(event) => {
183
+ event.stopPropagation();
184
+ if (event.key !== "Enter") {
185
+ return;
186
+ }
187
+ // Enter escolhe o que a busca deixou na tela — sem isto, filtrar
188
+ // pelo teclado ainda terminaria no mouse.
189
+ event.preventDefault();
190
+ if (visiveis[0]) {
191
+ alternar(visiveis[0].id);
192
+ }
193
+ }}
194
+ />
195
+ </div>
196
+
197
+ {visiveis.map((option) => (
198
+ <button
199
+ key={option.id}
200
+ type="button"
201
+ className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
202
+ onClick={() => alternar(option.id)}
203
+ >
204
+ <span className="truncate">{option.name}</span>
205
+ {value.includes(option.id) && <Check className="h-4 w-4" />}
206
+ </button>
207
+ ))}
208
+
209
+ {visiveis.length === 0 && (
210
+ <p className="px-2 py-3 text-center text-sm text-muted-foreground">
211
+ {emptyMessage ?? t("common.noResults")}
212
+ </p>
213
+ )}
214
+ </DropdownMenuContent>
215
+ </DropdownMenu>
216
+ );
217
+ }
@@ -22,9 +22,9 @@ import {
22
22
  import {
23
23
  ArrowDown,
24
24
  ArrowUp,
25
+ ArrowUpDown,
25
26
  ChevronLeft,
26
27
  ChevronRight,
27
- ChevronsUpDown,
28
28
  Filter,
29
29
  SlidersHorizontal,
30
30
  } from "lucide-react";
@@ -307,11 +307,14 @@ export function DataTable<T extends RowData>({
307
307
  header.getContext(),
308
308
  )}
309
309
  {sorted === "asc" ? (
310
- <ArrowUp className="h-3.5 w-3.5" />
310
+ <ArrowUp className="h-3.5 w-3.5 text-primary" />
311
311
  ) : sorted === "desc" ? (
312
- <ArrowDown className="h-3.5 w-3.5" />
312
+ <ArrowDown className="h-3.5 w-3.5 text-primary" />
313
313
  ) : (
314
- <ChevronsUpDown className="h-3.5 w-3.5 opacity-50" />
314
+ // Seta dupla apagada na coluna livre e seta única
315
+ // em destaque na que ordena: sem a cor, achar a
316
+ // coluna ativa exige comparar o desenho de todas.
317
+ <ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
315
318
  )}
316
319
  </button>
317
320
  ) : (
@@ -0,0 +1,200 @@
1
+ "use client";
2
+
3
+ import { Calendar, ChevronLeft, ChevronRight } from "lucide-react";
4
+ import type { JSX } from "react";
5
+ import { useEffect, useRef, useState } from "react";
6
+
7
+ import {
8
+ addMonths,
9
+ formatDay,
10
+ fromIsoDay,
11
+ isSameDay,
12
+ isSameMonth,
13
+ monthGrid,
14
+ monthLabel,
15
+ today,
16
+ toIsoDay,
17
+ weekdayLabels,
18
+ } from "#core/_utils/calendar";
19
+ import { Button } from "#core/components/ui/button";
20
+ import { useI18n } from "#core/contexts";
21
+ import { cn } from "#core/lib/utils";
22
+
23
+ export interface DatePickerProps {
24
+ /** `"yyyy-MM-dd"` — o mesmo formato que o backend recebe e devolve. */
25
+ value?: string;
26
+ onChange: (day: string | undefined) => void;
27
+ disabled?: boolean;
28
+ /** Descreve o campo para quem usa leitor de tela. */
29
+ label?: string;
30
+ placeholder?: string;
31
+ }
32
+
33
+ /**
34
+ * Escolha de **uma** data, no mesmo calendário do `DateRangePicker`.
35
+ *
36
+ * Existe pelo mesmo motivo que o irmão de intervalo: o `<input type="date">`
37
+ * abre um calendário do navegador, que não aceita tema nem largura e muda de
38
+ * cara entre Chrome, Safari e Firefox — no meio de um formulário escuro, ele é
39
+ * a única coisa clara na tela.
40
+ *
41
+ * O valor continua sendo a string ISO do dia, e não `Date`: é o que o backend
42
+ * grava (`char(10)`) e o que evita a viagem por fuso que transforma dia 1º às
43
+ * 00:00 locais no dia 30 em UTC.
44
+ */
45
+ export function DatePicker({
46
+ value,
47
+ onChange,
48
+ disabled,
49
+ label,
50
+ placeholder,
51
+ }: DatePickerProps): JSX.Element {
52
+ const { t, locale } = useI18n();
53
+ const containerRef = useRef<HTMLDivElement>(null);
54
+ const [open, setOpen] = useState(false);
55
+
56
+ const selected = fromIsoDay(value);
57
+ const [month, setMonth] = useState<Date>(() => selected ?? today());
58
+
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
+ const pickDay = (day: Date): void => {
84
+ onChange(toIsoDay(day));
85
+ setOpen(false);
86
+ };
87
+
88
+ const dayClass = (day: Date): string => {
89
+ if (selected && isSameDay(day, selected)) {
90
+ return "bg-primary text-primary-foreground font-semibold";
91
+ }
92
+ if (isSameDay(day, today())) {
93
+ return "border border-primary/50";
94
+ }
95
+ return isSameMonth(day, month) ? "" : "text-muted-foreground/60";
96
+ };
97
+
98
+ return (
99
+ <div className="relative" ref={containerRef}>
100
+ <button
101
+ type="button"
102
+ disabled={disabled}
103
+ aria-haspopup="dialog"
104
+ aria-expanded={open}
105
+ aria-label={label}
106
+ onClick={() => setOpen((current) => !current)}
107
+ 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
+ >
109
+ <span className={cn("truncate", !value && "text-muted-foreground")}>
110
+ {value ? formatDay(value, locale) : (placeholder ?? t("filters.selectDate"))}
111
+ </span>
112
+ <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
113
+ </button>
114
+
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)}
134
+ </span>
135
+ <Button
136
+ type="button"
137
+ variant="ghost"
138
+ size="iconSm"
139
+ aria-label={t("filters.nextMonth")}
140
+ onClick={() => setMonth(addMonths(month, 1))}
141
+ >
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>
169
+
170
+ <div className="mt-2 flex justify-between border-t border-border pt-2">
171
+ <Button
172
+ type="button"
173
+ variant="ghost"
174
+ size="sm"
175
+ onClick={() => {
176
+ pickDay(today());
177
+ setMonth(today());
178
+ }}
179
+ >
180
+ {t("filters.today")}
181
+ </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>
196
+ </div>
197
+ )}
198
+ </div>
199
+ );
200
+ }
@@ -6,8 +6,10 @@ export * from "./button";
6
6
  export * from "./card";
7
7
  export * from "./checkbox";
8
8
  export * from "./column-visibility-modal";
9
+ export * from "./combobox";
9
10
  export * from "./confirm-dialog";
10
11
  export * from "./data-table";
12
+ export * from "./date-picker";
11
13
  export * from "./date-range-picker";
12
14
  export * from "./dialog";
13
15
  export * from "./dropdown-menu";
@@ -76,7 +76,6 @@ export function AuditScreen(): JSX.Element {
76
76
  id: "user",
77
77
  header: t("auditTrail.user"),
78
78
  meta: { label: t("auditTrail.user") },
79
- enableSorting: false,
80
79
  cell: ({ row }) =>
81
80
  row.original.user?.name ?? t("auditTrail.systemUser"),
82
81
  },
@@ -84,7 +83,6 @@ export function AuditScreen(): JSX.Element {
84
83
  accessorKey: "ipAddress",
85
84
  header: t("auditTrail.ip"),
86
85
  meta: { label: t("auditTrail.ip") },
87
- enableSorting: false,
88
86
  cell: ({ getValue }) => (
89
87
  <span className="font-mono text-xs">{String(getValue() ?? "—")}</span>
90
88
  ),
@@ -95,7 +95,6 @@ export function RequestLogsPanel(): JSX.Element {
95
95
  id: "route",
96
96
  header: t("logs.route"),
97
97
  meta: { label: t("logs.route") },
98
- enableSorting: false,
99
98
  // A ação nomeada (`@Auditable("Login")`) diz mais que o caminho; sem
100
99
  // ela, o caminho é o que identifica a requisição.
101
100
  cell: ({ row }) => (
@@ -118,7 +117,6 @@ export function RequestLogsPanel(): JSX.Element {
118
117
  accessorKey: "errorType",
119
118
  header: t("logs.type"),
120
119
  meta: { label: t("logs.type") },
121
- enableSorting: false,
122
120
  cell: ({ getValue }) => {
123
121
  const type = getValue() as ErrorType | null;
124
122
  return type == null ? "—" : t(ErrorTypeLabelKey[type]);
@@ -137,7 +135,6 @@ export function RequestLogsPanel(): JSX.Element {
137
135
  id: "user",
138
136
  header: t("logs.user"),
139
137
  meta: { label: t("logs.user") },
140
- enableSorting: false,
141
138
  cell: ({ row }) => row.original.user?.name ?? t("logs.systemUser"),
142
139
  },
143
140
  {
@@ -50,7 +50,10 @@ const THEME_OPTIONS: { value: ColorPreference; key: string }[] = [
50
50
  export function ProfileScreen(): JSX.Element {
51
51
  const { user, logout, refreshProfile } = useAuth();
52
52
  const { preference, setPreference } = useColorMode();
53
- const { run, loading } = useRequest();
53
+ // `error` vinha sendo ignorado: o `useRequest` guarda a mensagem do servidor
54
+ // mas não notifica, então um 400 de validação ficava invisível — a tela não
55
+ // salvava e nada dizia por quê.
56
+ const { run, loading, error, setError } = useRequest();
54
57
  // useRequest próprio da troca de senha: o `run` compartilhado da tela zeraria
55
58
  // esse erro a cada outra ação (salvar perfil, trocar avatar).
56
59
  const password = useRequest();
@@ -82,7 +85,37 @@ export function ProfileScreen(): JSX.Element {
82
85
  // eslint-disable-next-line react-hooks/exhaustive-deps
83
86
  }, []);
84
87
 
88
+ const [formErrors, setFormErrors] = useState<Partial<typeof form>>({});
89
+
90
+ /**
91
+ * As mesmas regras do `UpdateProfileCommand`, do lado de cá.
92
+ *
93
+ * Telefone fica de fora quando está em branco: ele é opcional, e o backend
94
+ * lê o vazio como "apagar o telefone".
95
+ */
96
+ const validateProfile = (): boolean => {
97
+ const errors: Partial<typeof form> = {};
98
+
99
+ if (form.name.trim().length < 2) {
100
+ errors.name = t("validation.minLength", { count: 2 });
101
+ }
102
+ if (form.lastName.trim().length < 2) {
103
+ errors.lastName = t("validation.minLength", { count: 2 });
104
+ }
105
+ if (form.phone.trim() !== "" && form.phone.trim().length < 8) {
106
+ errors.phone = t("validation.minLength", { count: 8 });
107
+ }
108
+
109
+ setFormErrors(errors);
110
+ return Object.keys(errors).length === 0;
111
+ };
112
+
85
113
  const saveProfile = async (): Promise<void> => {
114
+ setError(null);
115
+ if (!validateProfile()) {
116
+ return;
117
+ }
118
+
86
119
  const res = await run(() => authService.updateProfile(form), {
87
120
  success: t("profile.saved"),
88
121
  });
@@ -212,19 +245,22 @@ export function ProfileScreen(): JSX.Element {
212
245
  </div>
213
246
 
214
247
  <h2 className="text-lg font-semibold">{t("profile.data")}</h2>
215
- <Field label={t("users.name")}>
248
+ <Field label={t("users.name")} error={formErrors.name}>
216
249
  <Input
217
250
  value={form.name}
218
251
  onChange={(e) => setForm({ ...form, name: e.target.value })}
219
252
  />
220
253
  </Field>
221
- <Field label={t("users.lastName")}>
254
+ <Field label={t("users.lastName")} error={formErrors.lastName}>
222
255
  <Input
223
256
  value={form.lastName}
224
257
  onChange={(e) => setForm({ ...form, lastName: e.target.value })}
225
258
  />
226
259
  </Field>
227
- <Field label={t("users.phone")}>
260
+ <Field
261
+ label={`${t("users.phone")} (${t("common.optional")})`}
262
+ error={formErrors.phone}
263
+ >
228
264
  <Input
229
265
  placeholder="(11) 90000-0000"
230
266
  value={form.phone}
@@ -233,6 +269,13 @@ export function ProfileScreen(): JSX.Element {
233
269
  }
234
270
  />
235
271
  </Field>
272
+
273
+ {/* O que o servidor recusou — regra que o cliente não conhece. */}
274
+ {error && (
275
+ <Alert variant="error" className="text-sm">
276
+ {error}
277
+ </Alert>
278
+ )}
236
279
  <p className="text-sm text-muted-foreground">
237
280
  <strong>{t("users.email")}:</strong> {user?.email} ·{" "}
238
281
  <strong>{t("dashboard.roles")}:</strong> {user?.roles.join(", ")}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import type { SortingState } from "@tanstack/react-table";
3
4
  import { useCallback, useEffect, useState } from "react";
4
5
 
5
6
  import {
@@ -25,9 +26,11 @@ export interface UseQueueJobsResult {
25
26
  page: number;
26
27
  pageSize: number;
27
28
  state: QueueJobState;
29
+ sorting: SortingState;
28
30
  loading: boolean;
29
31
  setPage: (page: number) => void;
30
32
  setPageSize: (size: number) => void;
33
+ setSorting: (sorting: SortingState) => void;
31
34
  setState: (state: QueueJobState) => void;
32
35
  refresh: () => Promise<void>;
33
36
  retry: (id: string) => Promise<void>;
@@ -49,11 +52,22 @@ export function useQueueJobs(): UseQueueJobsResult {
49
52
  const [page, setPage] = useState(0);
50
53
  const [pageSize, setPageSize] = useState(20);
51
54
  const [state, setStateValue] = useState<QueueJobState>(QueueJobState.FAILED);
55
+ // Sem ordem escolhida, a lista sai na ordem do Redis — que é a mais recente
56
+ // primeiro e não custa varredura nenhuma no backend.
57
+ const [sorting, setSorting] = useState<SortingState>([]);
52
58
 
53
59
  const fetch = useCallback(async (): Promise<void> => {
54
60
  const [list, summary] = await Promise.all([
55
61
  // O `page` da tabela é base zero; o da API, base um.
56
- run(() => queuesService.list({ state, page: page + 1, limit: pageSize })),
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
+ ),
57
71
  run(() => queuesService.counts()),
58
72
  ]);
59
73
  if (list) {
@@ -63,7 +77,7 @@ export function useQueueJobs(): UseQueueJobsResult {
63
77
  if (summary) {
64
78
  setCounts(summary);
65
79
  }
66
- }, [run, state, page, pageSize]);
80
+ }, [run, state, page, pageSize, sorting]);
67
81
 
68
82
  useEffect(() => {
69
83
  void fetch();
@@ -92,9 +106,11 @@ export function useQueueJobs(): UseQueueJobsResult {
92
106
  page,
93
107
  pageSize,
94
108
  state,
109
+ sorting,
95
110
  loading,
96
111
  setPage,
97
112
  setPageSize,
113
+ setSorting,
98
114
  setState,
99
115
  refresh: fetch,
100
116
  retry: (id) => act(queuesService.retry(id)),
@@ -4,6 +4,7 @@ import type {
4
4
  ColumnDef,
5
5
  OnChangeFn,
6
6
  PaginationState,
7
+ SortingState,
7
8
  } from "@tanstack/react-table";
8
9
  import { RotateCcw, Trash2 } from "lucide-react";
9
10
  import type { JSX } from "react";
@@ -90,7 +91,6 @@ export function QueuesScreen(): JSX.Element {
90
91
  accessorKey: "name",
91
92
  header: t("queues.job"),
92
93
  meta: { label: t("queues.job") },
93
- enableSorting: false,
94
94
  cell: ({ row }) => (
95
95
  <div className="flex flex-col">
96
96
  <span className="font-medium">{row.original.name}</span>
@@ -104,7 +104,6 @@ export function QueuesScreen(): JSX.Element {
104
104
  accessorKey: "state",
105
105
  header: t("queues.state"),
106
106
  meta: { label: t("queues.state") },
107
- enableSorting: false,
108
107
  cell: ({ row }) => {
109
108
  const state = row.original.state as QueueJobState;
110
109
  return (
@@ -118,7 +117,6 @@ export function QueuesScreen(): JSX.Element {
118
117
  accessorKey: "percent",
119
118
  header: t("queues.progress"),
120
119
  meta: { label: t("queues.progress") },
121
- enableSorting: false,
122
120
  cell: ({ getValue }) => {
123
121
  const percent = getValue() as number | null;
124
122
  return percent == null ? "—" : `${percent}%`;
@@ -128,20 +126,17 @@ export function QueuesScreen(): JSX.Element {
128
126
  accessorKey: "attemptsMade",
129
127
  header: t("queues.attempts"),
130
128
  meta: { label: t("queues.attempts") },
131
- enableSorting: false,
132
129
  },
133
130
  {
134
131
  accessorKey: "createdAt",
135
132
  header: t("queues.createdAt"),
136
133
  meta: { label: t("queues.createdAt") },
137
- enableSorting: false,
138
134
  cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
139
135
  },
140
136
  {
141
137
  accessorKey: "failedReason",
142
138
  header: t("queues.reason"),
143
139
  meta: { label: t("queues.reason") },
144
- enableSorting: false,
145
140
  // Cru e por inteiro no `title`: quem lê esta tela é quem vai consertar
146
141
  // a causa, e a mensagem truncada some justamente na parte útil.
147
142
  cell: ({ getValue }) => {
@@ -161,9 +156,9 @@ export function QueuesScreen(): JSX.Element {
161
156
  {
162
157
  id: "actions",
163
158
  header: () => <div className="text-center">{t("common.actions")}</div>,
159
+ enableSorting: false,
164
160
  meta: { shrink: true },
165
161
  enableHiding: false,
166
- enableSorting: false,
167
162
  cell: ({ row }) =>
168
163
  canManage ? (
169
164
  <RowActions>
@@ -196,6 +191,13 @@ export function QueuesScreen(): JSX.Element {
196
191
  pageIndex: queue.page,
197
192
  pageSize: queue.pageSize,
198
193
  };
194
+ /** Ordem nova recomeça na primeira página: a página 3 era da ordem antiga. */
195
+ const onSortingChange: OnChangeFn<SortingState> = (updater) => {
196
+ const next = typeof updater === "function" ? updater(queue.sorting) : updater;
197
+ queue.setSorting(next);
198
+ queue.setPage(0);
199
+ };
200
+
199
201
  const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
200
202
  const next = typeof updater === "function" ? updater(pagination) : updater;
201
203
  if (next.pageSize !== queue.pageSize) {
@@ -246,7 +248,9 @@ export function QueuesScreen(): JSX.Element {
246
248
  rowCount={queue.total}
247
249
  pagination={pagination}
248
250
  onPaginationChange={onPaginationChange}
249
- enableSorting={false}
251
+ manualSorting
252
+ sorting={queue.sorting}
253
+ onSortingChange={onSortingChange}
250
254
  emptyMessage={t("queues.empty")}
251
255
  toolbar={
252
256
  <SegmentedControl
@@ -28,6 +28,9 @@ export interface QueueJobsQuery {
28
28
  state: QueueJobState;
29
29
  page: number;
30
30
  limit: number;
31
+ /** Campo da whitelist do backend (`SORTABLE_JOB_FIELDS`). */
32
+ sortBy?: string;
33
+ sortDir?: "ASC" | "DESC";
31
34
  }
32
35
 
33
36
  export const queuesService = {
@@ -159,9 +159,12 @@ export function GroupsPanel(): JSX.Element {
159
159
  { accessorKey: "type", header: "Tipo", meta: { label: "Tipo" } },
160
160
  {
161
161
  id: "manager",
162
+ accessorFn: (row) => {
163
+ const manager = candidates.find((c) => c.id === row.managerId);
164
+ return manager ? fullName(manager) : "";
165
+ },
162
166
  header: t("rbac.manager"),
163
167
  meta: { label: t("rbac.manager") },
164
- enableSorting: false,
165
168
  // Sem gerente, o escopo `team` não alcança ninguém aqui dentro — e é
166
169
  // exatamente isso que o traço precisa deixar visível na listagem.
167
170
  cell: ({ row }) => {
@@ -90,7 +90,6 @@ export function UsersScreen(): JSX.Element {
90
90
  id: "roles",
91
91
  header: t("users.roles"),
92
92
  meta: { label: t("users.roles") },
93
- enableSorting: false,
94
93
  cell: ({ row }) => (
95
94
  <div className="flex flex-wrap gap-1">
96
95
  {row.original.roles?.map((r) => (
@@ -125,7 +124,6 @@ export function UsersScreen(): JSX.Element {
125
124
  accessorKey: "twoFactorEnabled",
126
125
  header: "2FA",
127
126
  meta: { label: "2FA" },
128
- enableSorting: false,
129
127
  cell: ({ row }) => (
130
128
  <Badge
131
129
  variant={row.original.twoFactorEnabled ? "success" : "warning"}
@@ -135,6 +135,8 @@ export const en: Messages = {
135
135
  fromChip: "from",
136
136
  toChip: "until",
137
137
  selectPeriod: "Select a period",
138
+ selectDate: "Select a date",
139
+ today: "Today",
138
140
  lastDays: "{days} days",
139
141
  previousMonth: "Previous month",
140
142
  nextMonth: "Next month",
@@ -187,6 +189,7 @@ export const en: Messages = {
187
189
  },
188
190
  common: {
189
191
  save: "Save",
192
+ optional: "optional",
190
193
  cancel: "Cancel",
191
194
  edit: "Edit",
192
195
  delete: "Delete",
@@ -194,6 +197,9 @@ export const en: Messages = {
194
197
  confirm: "Confirm",
195
198
  send: "Send",
196
199
  yes: "Yes",
200
+ select: "Select…",
201
+ search: "Search…",
202
+ noResults: "Nothing found",
197
203
  no: "No",
198
204
  actions: "Actions",
199
205
  loading: "Loading...",
@@ -343,6 +349,7 @@ export const en: Messages = {
343
349
  },
344
350
  validation: {
345
351
  required: "Required field",
352
+ minLength: "At least {count} characters",
346
353
  email: "Invalid email",
347
354
  passwordStrong: "Min. 8 chars with uppercase, lowercase, number and symbol",
348
355
  passwordsNotMatch: "Passwords do not match",
@@ -137,6 +137,8 @@ export const pt = {
137
137
  fromChip: "a partir de",
138
138
  toChip: "até",
139
139
  selectPeriod: "Selecione o período",
140
+ selectDate: "Selecione a data",
141
+ today: "Hoje",
140
142
  lastDays: "{days} dias",
141
143
  previousMonth: "Mês anterior",
142
144
  nextMonth: "Próximo mês",
@@ -189,6 +191,7 @@ export const pt = {
189
191
  },
190
192
  common: {
191
193
  save: "Salvar",
194
+ optional: "opcional",
192
195
  cancel: "Cancelar",
193
196
  edit: "Editar",
194
197
  delete: "Excluir",
@@ -196,6 +199,9 @@ export const pt = {
196
199
  confirm: "Confirmar",
197
200
  send: "Enviar",
198
201
  yes: "Sim",
202
+ select: "Selecionar…",
203
+ search: "Pesquisar…",
204
+ noResults: "Nada encontrado",
199
205
  no: "Não",
200
206
  actions: "Ações",
201
207
  loading: "Carregando...",
@@ -347,6 +353,7 @@ export const pt = {
347
353
  },
348
354
  validation: {
349
355
  required: "Campo obrigatório",
356
+ minLength: "No mínimo {count} caracteres",
350
357
  email: "Email inválido",
351
358
  passwordStrong:
352
359
  "Mín. 8 caracteres com maiúscula, minúscula, número e símbolo",
package/src/index.ts CHANGED
@@ -58,6 +58,14 @@ export { cn } from "#core/lib/utils";
58
58
  // O projeto que criar tela com escopo de equipe monta o par de códigos com ele,
59
59
  // em vez de repetir os sufixos à mão.
60
60
  export { anyOrTeam } from "#core/_utils/permission";
61
+ // Máscaras de campo: sem elas públicas, cada projeto reescreve a sua — e a
62
+ // segunda cópia é sempre a que esquece o teto de dígitos.
63
+ export {
64
+ formatCurrencyBR,
65
+ maskCurrencyBR,
66
+ maskPhoneBR,
67
+ parseCurrencyBR,
68
+ } from "#core/_utils/format";
61
69
 
62
70
  // ---------------------------------------------------------------------------
63
71
  // Hooks de listagem, requisição e filtro