rl-core-front 0.16.3 → 0.16.4

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.16.3",
3
+ "version": "0.16.4",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Avaliador de conta escrita à mão — o que a calculadora do campo de dinheiro
3
+ * usa para transformar `120+35*2` em `190`.
4
+ *
5
+ * **Não usa `eval` nem `new Function`.** O que se digita num campo é texto de
6
+ * usuário, e texto de usuário que vira código executável é a porta aberta
7
+ * clássica; além disso, `eval` aceitaria `1+1;alert(1)` e devolveria número,
8
+ * enquanto este parser recusa o que não entende em vez de tentar adivinhar.
9
+ *
10
+ * O que ele entende: `+ - * / %`, parênteses, sinal negativo, e número no
11
+ * formato brasileiro (ponto de milhar, vírgula decimal).
12
+ */
13
+
14
+ /**
15
+ * Por que a conta não fechou. É código e não frase pronta porque a mensagem é
16
+ * texto de tela: quem traduz é o componente, com o dicionário de quem está
17
+ * olhando.
18
+ */
19
+ export enum CalcErrorCode {
20
+ /** Apareceu um caractere que não é número nem operador. */
21
+ UNKNOWN_CHARACTER = "unknownCharacter",
22
+ /** O que parece número não é: `1,2,3`. */
23
+ INVALID_NUMBER = "invalidNumber",
24
+ /** A conta para no meio: `120+`. */
25
+ INCOMPLETE = "incomplete",
26
+ /** Abriu parêntese e não fechou. */
27
+ UNCLOSED_PAREN = "unclosedParen",
28
+ /** Fechou parêntese que ninguém abriu, ou sobrou algo depois do fim. */
29
+ TRAILING = "trailing",
30
+ DIVISION_BY_ZERO = "divisionByZero",
31
+ }
32
+
33
+ /** O erro que o parser lança. O `code` é o que vira mensagem. */
34
+ export class CalcError extends Error {
35
+ constructor(
36
+ readonly code: CalcErrorCode,
37
+ /** O caractere recusado, quando o código é `UNKNOWN_CHARACTER`. */
38
+ readonly character?: string,
39
+ ) {
40
+ super(code);
41
+ this.name = "CalcError";
42
+ }
43
+ }
44
+
45
+ type SymbolType = "+" | "-" | "*" | "/" | "%" | "(" | ")";
46
+
47
+ interface NumberToken {
48
+ type: "number";
49
+ value: number;
50
+ }
51
+
52
+ interface SymbolToken {
53
+ type: SymbolType;
54
+ }
55
+
56
+ type Token = NumberToken | SymbolToken;
57
+
58
+ /** O que compõe um número: dígito, ponto de milhar e vírgula decimal. */
59
+ const NUMBER_PART = /[0-9.,]/;
60
+
61
+ /**
62
+ * Os operadores que a tela desenha diferente de como o parser os lê.
63
+ *
64
+ * Uma tabela só, percorrida nos dois sentidos: daqui saem o desenho das teclas
65
+ * e o texto do visor, e por ela o tokenizador volta ao símbolo canônico. Antes
66
+ * o `×` estava escrito em três lugares, e acrescentar um operador significava
67
+ * lembrar dos três.
68
+ */
69
+ const OPERATOR_GLYPHS: ReadonlyArray<readonly [SymbolType, string]> = [
70
+ ["*", "×"],
71
+ ["/", "÷"],
72
+ ["-", "−"],
73
+ ];
74
+
75
+ const GLYPH_TO_SYMBOL = new Map<string, SymbolType>([
76
+ ...OPERATOR_GLYPHS.map(([symbol, glyph]) => [glyph, symbol] as const),
77
+ // O `x` do teclado de quem aprendeu a multiplicar assim.
78
+ ["x", "*"] as const,
79
+ ["X", "*"] as const,
80
+ ]);
81
+
82
+ const SYMBOL_TO_GLYPH = new Map<string, string>(OPERATOR_GLYPHS);
83
+
84
+ const SYMBOLS = "+-*/%()";
85
+
86
+ /**
87
+ * A conta como se lê na tela: `120*2+10%` vira `120×2+10%`.
88
+ *
89
+ * Mora aqui, junto do que faz o caminho de volta, e não no componente: são a
90
+ * mesma correspondência, e separadas elas saem de sincronia calada.
91
+ */
92
+ export const toDisplayExpression = (source: string): string =>
93
+ Array.from(source)
94
+ .map((character) => SYMBOL_TO_GLYPH.get(character) ?? character)
95
+ .join("");
96
+
97
+ /**
98
+ * O caractere pode entrar numa conta?
99
+ *
100
+ * É o que o teclado físico consulta antes de escrever. Sem isso, a lista de
101
+ * caracteres aceitos existiria duas vezes — aqui e num regex do componente —,
102
+ * e um operador novo entraria pelo botão mas não pela tecla.
103
+ */
104
+ export const isCalcCharacter = (character: string): boolean =>
105
+ NUMBER_PART.test(character) ||
106
+ SYMBOLS.includes(character) ||
107
+ GLYPH_TO_SYMBOL.has(character);
108
+
109
+ /**
110
+ * Número brasileiro para `number`.
111
+ *
112
+ * A ambiguidade real é o ponto sozinho: `1.200` é mil e duzentos para quem
113
+ * digita em reais, e 1,2 para o `Number`. A regra é a do país — havendo
114
+ * vírgula, todo ponto é milhar; sem vírgula, o ponto ainda é milhar quando
115
+ * separa exatamente três dígitos no fim, ou quando aparece mais de uma vez.
116
+ */
117
+ const toNumber = (raw: string): number => {
118
+ let text: string = raw;
119
+
120
+ if (text.includes(",")) {
121
+ text = text.replace(/\./g, "").replace(/,/g, ".");
122
+ } else if ((text.match(/\./g) ?? []).length > 1 || /\d\.\d{3}$/.test(text)) {
123
+ text = text.replace(/\./g, "");
124
+ }
125
+
126
+ const parsed: number = Number(text);
127
+ if (text === "" || !Number.isFinite(parsed)) {
128
+ throw new CalcError(CalcErrorCode.INVALID_NUMBER);
129
+ }
130
+
131
+ return parsed;
132
+ };
133
+
134
+ const tokenize = (source: string): Token[] => {
135
+ const tokens: Token[] = [];
136
+ let index = 0;
137
+
138
+ while (index < source.length) {
139
+ const character: string = source[index];
140
+
141
+ if (character === " ") {
142
+ index += 1;
143
+ continue;
144
+ }
145
+
146
+ if (NUMBER_PART.test(character)) {
147
+ let end: number = index;
148
+ while (end < source.length && NUMBER_PART.test(source[end])) {
149
+ end += 1;
150
+ }
151
+ tokens.push({ type: "number", value: toNumber(source.slice(index, end)) });
152
+ index = end;
153
+ continue;
154
+ }
155
+
156
+ const symbol: string = GLYPH_TO_SYMBOL.get(character) ?? character;
157
+ if (SYMBOLS.includes(symbol)) {
158
+ tokens.push({ type: symbol as SymbolType });
159
+ index += 1;
160
+ continue;
161
+ }
162
+
163
+ throw new CalcError(CalcErrorCode.UNKNOWN_CHARACTER, character);
164
+ }
165
+
166
+ return tokens;
167
+ };
168
+
169
+ /**
170
+ * Um pedaço já resolvido da conta.
171
+ *
172
+ * `percent` marca o número que ainda espera o contexto do operador anterior: é
173
+ * o que faz `200+10%` valer 220 e `200*10%` valer 20 — a porcentagem da
174
+ * calculadora de bolso, que é a que quem lança despesa tem na cabeça.
175
+ */
176
+ interface Operand {
177
+ value: number;
178
+ percent: boolean;
179
+ }
180
+
181
+ const parse = (tokens: Token[]): number => {
182
+ let position = 0;
183
+
184
+ const peek = (): Token | undefined => tokens[position];
185
+
186
+ const eat = (type: SymbolType): boolean => {
187
+ const token: Token | undefined = peek();
188
+ if (token && token.type === type) {
189
+ position += 1;
190
+ return true;
191
+ }
192
+ return false;
193
+ };
194
+
195
+ const primary = (): Operand => {
196
+ const token: Token | undefined = peek();
197
+ if (!token) {
198
+ throw new CalcError(CalcErrorCode.INCOMPLETE);
199
+ }
200
+
201
+ if (token.type === "number") {
202
+ position += 1;
203
+ return { value: token.value, percent: false };
204
+ }
205
+
206
+ if (token.type === "(") {
207
+ position += 1;
208
+ const inner: Operand = expression();
209
+ if (!eat(")")) {
210
+ throw new CalcError(CalcErrorCode.UNCLOSED_PAREN);
211
+ }
212
+ return { value: inner.value, percent: false };
213
+ }
214
+
215
+ // Sinal na frente do número: `-50` e `+50`.
216
+ if (token.type === "-") {
217
+ position += 1;
218
+ const operand: Operand = unary();
219
+ return { value: -operand.value, percent: operand.percent };
220
+ }
221
+ if (token.type === "+") {
222
+ position += 1;
223
+ return unary();
224
+ }
225
+
226
+ throw new CalcError(CalcErrorCode.INCOMPLETE);
227
+ };
228
+
229
+ const unary = (): Operand => {
230
+ const base: Operand = primary();
231
+ return eat("%") ? { value: base.value, percent: true } : base;
232
+ };
233
+
234
+ const term = (): Operand => {
235
+ let left: Operand = unary();
236
+
237
+ while (peek()?.type === "*" || peek()?.type === "/") {
238
+ const operator: SymbolType = (tokens[position] as SymbolToken).type;
239
+ position += 1;
240
+ const right: Operand = unary();
241
+ // Multiplicando ou dividindo, `10%` é simplesmente 0,1.
242
+ const factor: number = right.percent ? right.value / 100 : right.value;
243
+
244
+ if (operator === "/" && factor === 0) {
245
+ throw new CalcError(CalcErrorCode.DIVISION_BY_ZERO);
246
+ }
247
+
248
+ left = {
249
+ value: operator === "*" ? left.value * factor : left.value / factor,
250
+ percent: false,
251
+ };
252
+ }
253
+
254
+ return left;
255
+ };
256
+
257
+ const expression = (): Operand => {
258
+ let left: Operand = term();
259
+
260
+ while (peek()?.type === "+" || peek()?.type === "-") {
261
+ const operator: SymbolType = (tokens[position] as SymbolToken).type;
262
+ position += 1;
263
+ const right: Operand = term();
264
+ // Somando ou subtraindo, `10%` é 10% do que veio antes.
265
+ const delta: number = right.percent
266
+ ? (left.value * right.value) / 100
267
+ : right.value;
268
+
269
+ left = {
270
+ value: operator === "+" ? left.value + delta : left.value - delta,
271
+ percent: false,
272
+ };
273
+ }
274
+
275
+ return left;
276
+ };
277
+
278
+ const result: Operand = expression();
279
+ if (position < tokens.length) {
280
+ throw new CalcError(CalcErrorCode.TRAILING);
281
+ }
282
+
283
+ return result.value;
284
+ };
285
+
286
+ /**
287
+ * Resolve a conta e devolve o valor **já arredondado em centavos**.
288
+ *
289
+ * O arredondamento não é enfeite: `0,1+0,2` em ponto flutuante dá
290
+ * 0,30000000000000004, e o destino do resultado é uma coluna `decimal(12,2)`.
291
+ *
292
+ * Lança `CalcError` quando a conta não fecha — inclusive para texto vazio, que
293
+ * é `INCOMPLETE`.
294
+ */
295
+ export const evaluateExpression = (source: string): number => {
296
+ const value: number = parse(tokenize(source));
297
+
298
+ if (!Number.isFinite(value)) {
299
+ throw new CalcError(CalcErrorCode.INVALID_NUMBER);
300
+ }
301
+
302
+ return Math.round((value + Number.EPSILON) * 100) / 100;
303
+ };
@@ -73,3 +73,24 @@ export function formatCurrencyBR(value: number | null | undefined): string {
73
73
  }
74
74
  return maskCurrencyBR(String(Math.round(value * 100)));
75
75
  }
76
+
77
+ /**
78
+ * Real por extenso, com o símbolo: `R$ 1.234,56`.
79
+ *
80
+ * Diferente do `formatCurrencyBR`, que é a máscara do campo e por isso não tem
81
+ * símbolo — este é para leitura: extrato, cartão, eixo de gráfico, o visor da
82
+ * calculadora do `MoneyInput`. Escrever `"R$ " + formatCurrencyBR(v)` na tela
83
+ * parece a mesma coisa e não é: perde o espaço não-quebrável que o `Intl` põe
84
+ * entre símbolo e número, e o valor quebra linha no meio.
85
+ *
86
+ * Aceita texto porque `decimal` chega da API como string, e `Number(null)` é 0:
87
+ * o campo ausente vira `R$ 0,00` em vez de `R$ NaN`.
88
+ */
89
+ export function formatMoneyBR(
90
+ value: number | string | null | undefined,
91
+ ): string {
92
+ return Number(value ?? 0).toLocaleString("pt-BR", {
93
+ style: "currency",
94
+ currency: "BRL",
95
+ });
96
+ }
@@ -178,6 +178,16 @@ const ADMIN_CHILDREN: NavItem[] = [
178
178
  },
179
179
  { key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
180
180
  { key: "nav.logs", href: "/logs", icon: ScrollText, perm: "audit:read:any" },
181
+ // Logo abaixo do log de requisições, que é o vizinho natural: os dois contam
182
+ // o que aconteceu no sistema, um por chamada e outro por alteração de dado.
183
+ // Restrito a administradores — só quem tem audit:read-trail:any (só Super
184
+ // Admin) vê este item.
185
+ {
186
+ key: "nav.audit",
187
+ href: "/auditoria",
188
+ icon: History,
189
+ perm: "audit:read-trail:any",
190
+ },
181
191
  // Só aparece para quem opera a fila — em ambiente sem Redis ninguém tem a
182
192
  // permissão, e o item some sozinho.
183
193
  {
@@ -186,13 +196,6 @@ const ADMIN_CHILDREN: NavItem[] = [
186
196
  icon: ListChecks,
187
197
  perm: "queues:read:any",
188
198
  },
189
- // Restrito a administradores — só quem tem audit:read-trail:any (só Super Admin) vê este item.
190
- {
191
- key: "nav.audit",
192
- href: "/auditoria",
193
- icon: History,
194
- perm: "audit:read-trail:any",
195
- },
196
199
  ];
197
200
 
198
201
  export interface AppShellProps {
@@ -24,6 +24,7 @@ export * from "./image-upload";
24
24
  export * from "./input";
25
25
  export * from "./job-progress";
26
26
  export * from "./label";
27
+ export * from "./money-input";
27
28
  export * from "./password-requirements";
28
29
  export * from "./popover";
29
30
  export * from "./row-actions";
@@ -0,0 +1,314 @@
1
+ "use client";
2
+
3
+ import { Calculator, Delete } from "lucide-react";
4
+ import type { JSX, KeyboardEvent } from "react";
5
+ import { useRef, useState } from "react";
6
+
7
+ import {
8
+ CalcError,
9
+ CalcErrorCode,
10
+ evaluateExpression,
11
+ isCalcCharacter,
12
+ toDisplayExpression,
13
+ } from "#core/_utils/calc";
14
+ import {
15
+ formatCurrencyBR,
16
+ formatMoneyBR,
17
+ maskCurrencyBR,
18
+ parseCurrencyBR,
19
+ } from "#core/_utils/format";
20
+ import { Button } from "#core/components/ui/button";
21
+ import { Input } from "#core/components/ui/input";
22
+ import {
23
+ Popover,
24
+ PopoverAnchor,
25
+ PopoverContent,
26
+ PopoverTrigger,
27
+ } from "#core/components/ui/popover";
28
+ import { useI18n } from "#core/contexts";
29
+ import { cn } from "#core/lib/utils";
30
+
31
+ export interface MoneyInputProps {
32
+ /** O valor já mascarado — `1.234,56`. É o que o formulário guarda. */
33
+ value: string;
34
+ onChange: (value: string) => void;
35
+ onBlur?: () => void;
36
+ name?: string;
37
+ id?: string;
38
+ placeholder?: string;
39
+ disabled?: boolean;
40
+ }
41
+
42
+ /**
43
+ * O maior valor que a máscara do core representa: onze dígitos, dois deles
44
+ * centavos. Passar disso não dá erro — `maskCurrencyBR` corta os dígitos que
45
+ * sobram e devolve outro número, calado. Por isso a calculadora recusa antes.
46
+ */
47
+ const MAX_MASKED_VALUE = 999_999_999.99;
48
+
49
+ /** O que uma tecla faz: escreve na conta, ou mexe no que já está escrito. */
50
+ type KeyRole = "digit" | "operator" | "aux";
51
+
52
+ interface CalcKey {
53
+ /**
54
+ * O que a tecla acrescenta à conta — e também o seu desenho, passado pelo
55
+ * `toDisplayExpression`: a tecla do `*` mostra `×` sem que o glifo precise
56
+ * ser escrito aqui de novo.
57
+ */
58
+ input?: string;
59
+ role: KeyRole;
60
+ /** Rótulo acessível e desenho da tecla que não escreve na conta. */
61
+ labelKey?: string;
62
+ label?: string;
63
+ action?: "clear" | "backspace";
64
+ }
65
+
66
+ const KEYS: readonly CalcKey[] = [
67
+ { role: "aux", label: "C", action: "clear", labelKey: "calculator.clear" },
68
+ { role: "aux", input: "(" },
69
+ { role: "aux", input: ")" },
70
+ { role: "operator", input: "/" },
71
+ { role: "digit", input: "7" },
72
+ { role: "digit", input: "8" },
73
+ { role: "digit", input: "9" },
74
+ { role: "operator", input: "*" },
75
+ { role: "digit", input: "4" },
76
+ { role: "digit", input: "5" },
77
+ { role: "digit", input: "6" },
78
+ { role: "operator", input: "-" },
79
+ { role: "digit", input: "1" },
80
+ { role: "digit", input: "2" },
81
+ { role: "digit", input: "3" },
82
+ { role: "operator", input: "+" },
83
+ { role: "operator", input: "%" },
84
+ { role: "digit", input: "0" },
85
+ { role: "digit", input: "," },
86
+ { role: "aux", action: "backspace", labelKey: "calculator.backspace" },
87
+ ];
88
+
89
+ /** O resultado da conta, ou o motivo de ela não poder ser usada. */
90
+ interface CalcOutcome {
91
+ value: number | null;
92
+ message: string | null;
93
+ }
94
+
95
+ /**
96
+ * Campo de dinheiro com calculadora — a máscara do core mais um lugar para
97
+ * fazer a conta antes de lançar o valor.
98
+ *
99
+ * A conta acontece num painel, e não no próprio campo, porque a máscara entra
100
+ * **pela direita**, como caixa de supermercado: cada tecla empurra o valor uma
101
+ * casa, e um `+` digitado ali seria descartado antes de virar operador.
102
+ *
103
+ * Vem do core e não de cada projeto porque campo de dinheiro é o mesmo em
104
+ * todos: sem ele aqui, cada sistema remonta o par `Input` + `maskCurrencyBR` à
105
+ * mão em cada formulário, e a segunda cópia é a que esquece de recusar o
106
+ * resultado negativo.
107
+ */
108
+ export const MoneyInput = ({
109
+ value,
110
+ onChange,
111
+ onBlur,
112
+ name,
113
+ id,
114
+ placeholder,
115
+ disabled = false,
116
+ }: MoneyInputProps): JSX.Element => {
117
+ const { t } = useI18n();
118
+ const [open, setOpen] = useState(false);
119
+ const [expression, setExpression] = useState("");
120
+ const panelRef = useRef<HTMLDivElement>(null);
121
+
122
+ /**
123
+ * Traduz o erro do parser. O código vem do `calc.util`, que não conhece
124
+ * idioma nenhum — quem escolhe a frase é quem sabe quem está olhando.
125
+ */
126
+ const messageOf = (error: unknown): string => {
127
+ if (error instanceof CalcError) {
128
+ return t(`calculator.error.${error.code}`, {
129
+ character: error.character ?? "",
130
+ });
131
+ }
132
+ return t(`calculator.error.${CalcErrorCode.INCOMPLETE}`);
133
+ };
134
+
135
+ const outcome = ((): CalcOutcome => {
136
+ if (expression.trim() === "") {
137
+ return { value: null, message: null };
138
+ }
139
+
140
+ try {
141
+ const result: number = evaluateExpression(expression);
142
+
143
+ // A máscara não guarda sinal: um resultado negativo entraria no campo
144
+ // como positivo, e ninguém veria a troca acontecer.
145
+ if (result <= 0) {
146
+ return { value: null, message: t("calculator.error.notPositive") };
147
+ }
148
+ if (result > MAX_MASKED_VALUE) {
149
+ return { value: null, message: t("calculator.error.outOfRange") };
150
+ }
151
+
152
+ return { value: result, message: null };
153
+ } catch (error) {
154
+ return { value: null, message: messageOf(error) };
155
+ }
156
+ })();
157
+
158
+ /** Abrir semeia a conta com o que já está no campo, para somar em cima. */
159
+ const handleOpenChange = (next: boolean): void => {
160
+ if (next) {
161
+ setExpression(parseCurrencyBR(value) > 0 ? value : "");
162
+ }
163
+ setOpen(next);
164
+ };
165
+
166
+ const press = (key: CalcKey): void => {
167
+ if (key.action === "clear") {
168
+ setExpression("");
169
+ return;
170
+ }
171
+ if (key.action === "backspace") {
172
+ setExpression((current) => current.slice(0, -1));
173
+ return;
174
+ }
175
+ setExpression((current) => current + (key.input ?? ""));
176
+ };
177
+
178
+ const apply = (): void => {
179
+ if (outcome.value === null) {
180
+ return;
181
+ }
182
+ onChange(maskCurrencyBR(formatCurrencyBR(outcome.value)));
183
+ setOpen(false);
184
+ };
185
+
186
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
187
+ const onButton: boolean =
188
+ event.target instanceof HTMLElement && event.target.tagName === "BUTTON";
189
+
190
+ // Com o foco numa tecla, o Enter é o clique dela — mexer nisso quebraria
191
+ // quem navega por Tab.
192
+ if (event.key === "Enter" && !onButton) {
193
+ event.preventDefault();
194
+ apply();
195
+ return;
196
+ }
197
+ if (event.key === "Backspace") {
198
+ event.preventDefault();
199
+ setExpression((current) => current.slice(0, -1));
200
+ return;
201
+ }
202
+ if (event.key.length === 1 && isCalcCharacter(event.key)) {
203
+ event.preventDefault();
204
+ // No teclado numérico o decimal é ponto; em reais, é vírgula.
205
+ setExpression((current) => current + (event.key === "." ? "," : event.key));
206
+ }
207
+ };
208
+
209
+ return (
210
+ <Popover open={open} onOpenChange={handleOpenChange}>
211
+ <PopoverAnchor asChild>
212
+ <div className="relative">
213
+ <Input
214
+ id={id}
215
+ name={name}
216
+ value={value}
217
+ onChange={(event) => onChange(maskCurrencyBR(event.target.value))}
218
+ onBlur={onBlur}
219
+ placeholder={placeholder}
220
+ disabled={disabled}
221
+ prefix="R$"
222
+ inputMode="decimal"
223
+ className="pr-11"
224
+ />
225
+ <PopoverTrigger asChild>
226
+ <Button
227
+ type="button"
228
+ variant="ghost"
229
+ size="iconSm"
230
+ disabled={disabled}
231
+ aria-label={t("calculator.open")}
232
+ className={cn(
233
+ "absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-primary",
234
+ open && "bg-primary/15 text-primary",
235
+ )}
236
+ >
237
+ <Calculator />
238
+ </Button>
239
+ </PopoverTrigger>
240
+ </div>
241
+ </PopoverAnchor>
242
+
243
+ <PopoverContent
244
+ ref={panelRef}
245
+ align="end"
246
+ aria-label={t("calculator.title")}
247
+ onKeyDown={handleKeyDown}
248
+ // O foco fica no painel, e não na primeira tecla: assim o Enter usa o
249
+ // valor em vez de apertar o "C".
250
+ onOpenAutoFocus={(event) => {
251
+ event.preventDefault();
252
+ panelRef.current?.focus();
253
+ }}
254
+ tabIndex={-1}
255
+ className="w-72"
256
+ >
257
+ <div className="flex min-h-17 flex-col justify-between gap-1 rounded-md border border-border bg-muted/50 px-3 py-2 text-right">
258
+ <span className="min-h-5 break-all font-mono text-xs text-muted-foreground">
259
+ {toDisplayExpression(expression)}
260
+ </span>
261
+ {outcome.message ? (
262
+ <span className="break-words text-xs text-destructive">{outcome.message}</span>
263
+ ) : (
264
+ <span className="break-all font-mono text-lg font-medium tabular-nums">
265
+ {formatMoneyBR(outcome.value ?? 0)}
266
+ </span>
267
+ )}
268
+ </div>
269
+
270
+ <div className="mt-2 grid grid-cols-4 gap-1.5">
271
+ {KEYS.map((key, index) => (
272
+ <Button
273
+ key={key.label ?? key.action ?? index}
274
+ type="button"
275
+ variant={key.role === "aux" ? "outline" : "secondary"}
276
+ aria-label={key.labelKey ? t(key.labelKey) : undefined}
277
+ onClick={() => press(key)}
278
+ className={cn(
279
+ "h-9 px-0 font-mono text-sm",
280
+ key.role === "operator" && "text-primary",
281
+ key.role === "aux" && "text-muted-foreground",
282
+ )}
283
+ >
284
+ {key.label ?? (key.input ? toDisplayExpression(key.input) : <Delete />)}
285
+ </Button>
286
+ ))}
287
+ </div>
288
+
289
+ <div className="mt-2 flex gap-1.5">
290
+ <Button
291
+ type="button"
292
+ variant="outline"
293
+ className="h-9 flex-1"
294
+ onClick={() => setOpen(false)}
295
+ >
296
+ {t("calculator.cancel")}
297
+ </Button>
298
+ <Button
299
+ type="button"
300
+ className="h-9 flex-1"
301
+ disabled={outcome.value === null}
302
+ onClick={apply}
303
+ >
304
+ {t("calculator.apply")}
305
+ </Button>
306
+ </div>
307
+
308
+ <p className="mt-2 text-center text-[10px] text-muted-foreground">
309
+ {t("calculator.hint")}
310
+ </p>
311
+ </PopoverContent>
312
+ </Popover>
313
+ );
314
+ };
@@ -265,6 +265,25 @@ export const en: Messages = {
265
265
  markAllRead: "Mark all as read",
266
266
  empty: "No notifications",
267
267
  },
268
+ calculator: {
269
+ open: "Open the calculator",
270
+ title: "Calculator",
271
+ apply: "Use value",
272
+ cancel: "Cancel",
273
+ clear: "Clear",
274
+ backspace: "Delete last character",
275
+ hint: "Enter uses the value · Esc closes",
276
+ error: {
277
+ unknownCharacter: "“{character}” cannot be used in a calculation",
278
+ invalidNumber: "That is not a number",
279
+ incomplete: "The calculation is unfinished",
280
+ unclosedParen: "A parenthesis was left open",
281
+ trailing: "There is something after the end of the calculation",
282
+ divisionByZero: "Cannot divide by zero",
283
+ notPositive: "The result must be greater than zero",
284
+ outOfRange: "The result is above the highest value this field takes",
285
+ },
286
+ },
268
287
  common: {
269
288
  save: "Save",
270
289
  optional: "optional",
@@ -268,6 +268,25 @@ export const pt = {
268
268
  markAllRead: "Marcar todas como lidas",
269
269
  empty: "Nenhuma notificação",
270
270
  },
271
+ calculator: {
272
+ open: "Abrir a calculadora",
273
+ title: "Calculadora",
274
+ apply: "Usar valor",
275
+ cancel: "Cancelar",
276
+ clear: "Limpar a conta",
277
+ backspace: "Apagar o último caractere",
278
+ hint: "Enter usa o valor · Esc fecha",
279
+ error: {
280
+ unknownCharacter: "Não dá para usar “{character}” numa conta",
281
+ invalidNumber: "Esse número não existe",
282
+ incomplete: "Falta terminar a conta",
283
+ unclosedParen: "Falta fechar o parêntese",
284
+ trailing: "Sobrou algo depois do fim da conta",
285
+ divisionByZero: "Não dá para dividir por zero",
286
+ notPositive: "O resultado precisa ser maior que zero",
287
+ outOfRange: "O resultado passa do maior valor que o campo aceita",
288
+ },
289
+ },
271
290
  common: {
272
291
  save: "Salvar",
273
292
  optional: "opcional",
package/src/index.ts CHANGED
@@ -63,6 +63,7 @@ export { anyOrTeam } from "#core/_utils/permission";
63
63
  // segunda cópia é sempre a que esquece o teto de dígitos.
64
64
  export {
65
65
  formatCurrencyBR,
66
+ formatMoneyBR,
66
67
  maskCurrencyBR,
67
68
  maskPhoneBR,
68
69
  parseCurrencyBR,