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 +1 -1
- package/src/_utils/format.ts +40 -0
- package/src/components/ui/combobox.tsx +217 -0
- package/src/components/ui/data-table.tsx +7 -4
- package/src/components/ui/date-picker.tsx +200 -0
- package/src/components/ui/index.ts +2 -0
- package/src/features/audit/audit-screen.tsx +0 -2
- package/src/features/logs/panels/request-logs-panel.tsx +0 -3
- package/src/features/profile/profile-screen.tsx +47 -4
- package/src/features/queues/hooks/use-queue-jobs.ts +18 -2
- package/src/features/queues/queues-screen.tsx +12 -8
- package/src/features/queues/services/queues.service.ts +3 -0
- package/src/features/rbac/panels/groups-panel.tsx +4 -1
- package/src/features/users/users-screen.tsx +0 -2
- package/src/i18n/messages/en.ts +7 -0
- package/src/i18n/messages/pt.ts +7 -0
- package/src/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rl-core-front",
|
|
3
|
-
"version": "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",
|
package/src/_utils/format.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
251
|
+
manualSorting
|
|
252
|
+
sorting={queue.sorting}
|
|
253
|
+
onSortingChange={onSortingChange}
|
|
250
254
|
emptyMessage={t("queues.empty")}
|
|
251
255
|
toolbar={
|
|
252
256
|
<SegmentedControl
|
|
@@ -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"}
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -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",
|
package/src/i18n/messages/pt.ts
CHANGED
|
@@ -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
|