rl-core-front 0.9.0 → 0.10.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 +1 -1
- package/src/_utils/format.ts +40 -0
- package/src/components/ui/combobox.tsx +217 -0
- package/src/components/ui/date-picker.tsx +200 -0
- package/src/components/ui/index.ts +2 -0
- package/src/features/profile/profile-screen.tsx +47 -4
- 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.1",
|
|
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
|
+
}
|
|
@@ -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";
|
|
@@ -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(", ")}
|
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
|