rl-core-front 0.9.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.9.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
+ }
@@ -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
- 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(", ")}
@@ -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