rl-core-front 0.16.3 → 0.16.5

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.5",
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 {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Slot } from "@radix-ui/react-slot";
4
4
  import { cva, type VariantProps } from "class-variance-authority";
5
+ import { Loader2 } from "lucide-react";
5
6
  import * as React from "react";
6
7
 
7
8
  import { cn } from "#core/lib/utils";
@@ -43,22 +44,54 @@ export interface ButtonProps
43
44
  React.ButtonHTMLAttributes<HTMLButtonElement>,
44
45
  VariantProps<typeof buttonVariants> {
45
46
  asChild?: boolean;
47
+ /**
48
+ * A ação está em curso: mostra o giro e recusa clique.
49
+ *
50
+ * As duas coisas juntas de propósito. Salvar sem sinal nenhum é o que faz
51
+ * clicar de novo, e clicar de novo é o que duplica o lançamento — desabilitar
52
+ * sem mostrar por quê parece que o botão quebrou. Quem passa `loading` não
53
+ * precisa lembrar de passar `disabled` também.
54
+ *
55
+ * Ignorado com `asChild`: ali o filho é que decide o que desenha.
56
+ */
57
+ loading?: boolean;
46
58
  }
47
59
 
48
60
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
49
- ({ className, variant, size, asChild = false, ...props }, ref) => {
61
+ (
62
+ { className, variant, size, asChild = false, loading = false, children, ...props },
63
+ ref,
64
+ ) => {
50
65
  const Comp = asChild ? Slot : "button";
66
+
67
+ // O giro entra ANTES do conteúdo, no lugar onde o ícone do botão já ficava:
68
+ // aparecendo depois, o texto pula para a esquerda quando ele some.
69
+ const conteudo =
70
+ loading && !asChild ? (
71
+ <>
72
+ <Loader2 className="animate-spin" aria-hidden />
73
+ {children}
74
+ </>
75
+ ) : (
76
+ children
77
+ );
78
+
51
79
  return (
52
80
  <Comp
53
81
  className={cn(buttonVariants({ variant, size, className }))}
54
82
  ref={ref}
83
+ {...(asChild ? {} : { disabled: loading || props.disabled })}
84
+ // Quem usa leitor de tela não vê o giro: é o `aria-busy` que conta.
85
+ {...(loading && !asChild ? { "aria-busy": true } : {})}
55
86
  // `<button>` sem `type` nasce `submit`: dentro de um form, um botão que
56
87
  // só abre um modal acabava salvando o registro junto. Quem submete
57
88
  // declara `type="submit"` — o spread abaixo deixa isso sobrescrever.
58
89
  // Com `asChild` o filho pode não ser um `<button>`, então não força.
59
90
  {...(asChild ? {} : { type: "button" as const })}
60
91
  {...props}
61
- />
92
+ >
93
+ {conteudo}
94
+ </Comp>
62
95
  );
63
96
  },
64
97
  );
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import type { FocusEvent, JSX, MouseEvent, ReactNode } from "react";
4
+ import { useState } from "react";
5
+ import { createPortal } from "react-dom";
6
+
7
+ export interface HoverTipProps {
8
+ /** O que o tooltip diz. Vazio não desenha nada. */
9
+ label: string;
10
+ children: ReactNode;
11
+ }
12
+
13
+ /** Onde o rótulo aparece na tela, em coordenada de viewport. */
14
+ interface TipPosition {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ /** A folga entre o gatilho e o rótulo. */
20
+ const GAP = 6;
21
+
22
+ /**
23
+ * O rótulo que aparece **na hora** ao passar o mouse.
24
+ *
25
+ * Existe pelo mesmo motivo do `HoverLabel` dos gráficos: o `title` do HTML só
26
+ * aparece depois de cerca de um segundo parado, e um segundo é tempo suficiente
27
+ * para quem passou o mouse concluir que não há nada ali e seguir em frente.
28
+ * Numa coluna de cinco ícones, isso é a diferença entre descobrir o que cada um
29
+ * faz e clicar para descobrir.
30
+ *
31
+ * Abre **embaixo**, e sai do documento por um portal com posição fixa. As duas
32
+ * decisões são a mesma: o `Table` do core envolve a tabela num `overflow-x-auto`
33
+ * e, quando um eixo rola, o navegador recorta o outro também — dentro da célula,
34
+ * o rótulo da última linha era cortado pela borda da tabela. Abrir à esquerda,
35
+ * como já se tentou, cabia na caixa mas cobria o conteúdo da própria linha: na
36
+ * lista de receitas, o "Recebido" do botão tapava o "Pendente" da situação, que
37
+ * é justamente o dado que se confere antes de clicar.
38
+ *
39
+ * O estado mora aqui dentro e guarda só a coordenada. Ele re-renderiza este
40
+ * componente e o rótulo — não a linha da tabela, que entra por `children` e
41
+ * chega pronta de fora.
42
+ */
43
+ export const HoverTip = ({ label, children }: HoverTipProps): JSX.Element => {
44
+ const [position, setPosition] = useState<TipPosition | null>(null);
45
+
46
+ const show = (event: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>): void => {
47
+ const box = event.currentTarget.getBoundingClientRect();
48
+
49
+ setPosition({ x: box.left + box.width / 2, y: box.bottom + GAP });
50
+ };
51
+
52
+ const hide = (): void => setPosition(null);
53
+
54
+ return (
55
+ <span
56
+ className="relative inline-flex"
57
+ onMouseEnter={show}
58
+ onMouseLeave={hide}
59
+ // Chegando por teclado o rótulo também aparece: quem navega com Tab
60
+ // precisa do mesmo texto que o mouse mostra.
61
+ onFocus={show}
62
+ onBlur={hide}
63
+ >
64
+ {children}
65
+ {label &&
66
+ position &&
67
+ createPortal(
68
+ <span
69
+ role="tooltip"
70
+ style={{ left: position.x, top: position.y }}
71
+ className="pointer-events-none fixed z-100 max-w-[90vw] -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-popover px-2 py-0.5 text-[11px] font-medium text-popover-foreground shadow-lg"
72
+ >
73
+ {label}
74
+ </span>,
75
+ document.body,
76
+ )}
77
+ </span>
78
+ );
79
+ };
@@ -20,10 +20,12 @@ export * from "./filter-field";
20
20
  export * from "./filter-group";
21
21
  export * from "./filter-rule";
22
22
  export * from "./filter-sheet";
23
+ export * from "./hover-tip";
23
24
  export * from "./image-upload";
24
25
  export * from "./input";
25
26
  export * from "./job-progress";
26
27
  export * from "./label";
28
+ export * from "./money-input";
27
29
  export * from "./password-requirements";
28
30
  export * from "./popover";
29
31
  export * from "./row-actions";
@@ -0,0 +1,328 @@
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
+ * Foca o campo ao montar.
42
+ *
43
+ * Existe porque num modal cujo primeiro campo é o valor — a meta do mês, o
44
+ * ajuste de saldo — o `DialogContent` entrega o foco ao botão de fechar do
45
+ * cabeçalho, e quem abriu para digitar um número precisa de um Tab antes de
46
+ * começar.
47
+ */
48
+ autoFocus?: boolean;
49
+ }
50
+
51
+ /**
52
+ * O maior valor que a máscara do core representa: onze dígitos, dois deles
53
+ * centavos. Passar disso não dá erro — `maskCurrencyBR` corta os dígitos que
54
+ * sobram e devolve outro número, calado. Por isso a calculadora recusa antes.
55
+ */
56
+ const MAX_MASKED_VALUE = 999_999_999.99;
57
+
58
+ /** O que uma tecla faz: escreve na conta, ou mexe no que já está escrito. */
59
+ type KeyRole = "digit" | "operator" | "aux";
60
+
61
+ interface CalcKey {
62
+ /**
63
+ * O que a tecla acrescenta à conta — e também o seu desenho, passado pelo
64
+ * `toDisplayExpression`: a tecla do `*` mostra `×` sem que o glifo precise
65
+ * ser escrito aqui de novo.
66
+ */
67
+ input?: string;
68
+ role: KeyRole;
69
+ /** Rótulo acessível e desenho da tecla que não escreve na conta. */
70
+ labelKey?: string;
71
+ label?: string;
72
+ action?: "clear" | "backspace";
73
+ }
74
+
75
+ const KEYS: readonly CalcKey[] = [
76
+ { role: "aux", label: "C", action: "clear", labelKey: "calculator.clear" },
77
+ { role: "aux", input: "(" },
78
+ { role: "aux", input: ")" },
79
+ { role: "operator", input: "/" },
80
+ { role: "digit", input: "7" },
81
+ { role: "digit", input: "8" },
82
+ { role: "digit", input: "9" },
83
+ { role: "operator", input: "*" },
84
+ { role: "digit", input: "4" },
85
+ { role: "digit", input: "5" },
86
+ { role: "digit", input: "6" },
87
+ { role: "operator", input: "-" },
88
+ { role: "digit", input: "1" },
89
+ { role: "digit", input: "2" },
90
+ { role: "digit", input: "3" },
91
+ { role: "operator", input: "+" },
92
+ { role: "operator", input: "%" },
93
+ { role: "digit", input: "0" },
94
+ { role: "digit", input: "," },
95
+ { role: "aux", action: "backspace", labelKey: "calculator.backspace" },
96
+ ];
97
+
98
+ /** O resultado da conta, ou o motivo de ela não poder ser usada. */
99
+ interface CalcOutcome {
100
+ value: number | null;
101
+ message: string | null;
102
+ }
103
+
104
+ /**
105
+ * Campo de dinheiro com calculadora — a máscara do core mais um lugar para
106
+ * fazer a conta antes de lançar o valor.
107
+ *
108
+ * A conta acontece num painel, e não no próprio campo, porque a máscara entra
109
+ * **pela direita**, como caixa de supermercado: cada tecla empurra o valor uma
110
+ * casa, e um `+` digitado ali seria descartado antes de virar operador.
111
+ *
112
+ * Vem do core e não de cada projeto porque campo de dinheiro é o mesmo em
113
+ * todos: sem ele aqui, cada sistema remonta o par `Input` + `maskCurrencyBR` à
114
+ * mão em cada formulário, e a segunda cópia é a que esquece de recusar o
115
+ * resultado negativo.
116
+ */
117
+ export const MoneyInput = ({
118
+ value,
119
+ onChange,
120
+ onBlur,
121
+ name,
122
+ id,
123
+ placeholder,
124
+ disabled = false,
125
+ autoFocus = false,
126
+ }: MoneyInputProps): JSX.Element => {
127
+ const { t } = useI18n();
128
+ const [open, setOpen] = useState(false);
129
+ const [expression, setExpression] = useState("");
130
+ const panelRef = useRef<HTMLDivElement>(null);
131
+
132
+ /**
133
+ * Traduz o erro do parser. O código vem do `calc.util`, que não conhece
134
+ * idioma nenhum — quem escolhe a frase é quem sabe quem está olhando.
135
+ */
136
+ const messageOf = (error: unknown): string => {
137
+ if (error instanceof CalcError) {
138
+ return t(`calculator.error.${error.code}`, {
139
+ character: error.character ?? "",
140
+ });
141
+ }
142
+ return t(`calculator.error.${CalcErrorCode.INCOMPLETE}`);
143
+ };
144
+
145
+ const outcome = ((): CalcOutcome => {
146
+ if (expression.trim() === "") {
147
+ return { value: null, message: null };
148
+ }
149
+
150
+ try {
151
+ const result: number = evaluateExpression(expression);
152
+
153
+ // A máscara não guarda sinal: um resultado negativo entraria no campo
154
+ // como positivo, e ninguém veria a troca acontecer.
155
+ if (result <= 0) {
156
+ return { value: null, message: t("calculator.error.notPositive") };
157
+ }
158
+ if (result > MAX_MASKED_VALUE) {
159
+ return { value: null, message: t("calculator.error.outOfRange") };
160
+ }
161
+
162
+ return { value: result, message: null };
163
+ } catch (error) {
164
+ return { value: null, message: messageOf(error) };
165
+ }
166
+ })();
167
+
168
+ /** Abrir semeia a conta com o que já está no campo, para somar em cima. */
169
+ const handleOpenChange = (next: boolean): void => {
170
+ if (next) {
171
+ setExpression(parseCurrencyBR(value) > 0 ? value : "");
172
+ }
173
+ setOpen(next);
174
+ };
175
+
176
+ const press = (key: CalcKey): void => {
177
+ if (key.action === "clear") {
178
+ setExpression("");
179
+ return;
180
+ }
181
+ if (key.action === "backspace") {
182
+ setExpression((current) => current.slice(0, -1));
183
+ return;
184
+ }
185
+ setExpression((current) => current + (key.input ?? ""));
186
+ };
187
+
188
+ const apply = (): void => {
189
+ if (outcome.value === null) {
190
+ return;
191
+ }
192
+ onChange(maskCurrencyBR(formatCurrencyBR(outcome.value)));
193
+ setOpen(false);
194
+ };
195
+
196
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
197
+ const onButton: boolean =
198
+ event.target instanceof HTMLElement && event.target.tagName === "BUTTON";
199
+
200
+ // Com o foco numa tecla, o Enter é o clique dela — mexer nisso quebraria
201
+ // quem navega por Tab.
202
+ if (event.key === "Enter" && !onButton) {
203
+ event.preventDefault();
204
+ apply();
205
+ return;
206
+ }
207
+ if (event.key === "Backspace") {
208
+ event.preventDefault();
209
+ setExpression((current) => current.slice(0, -1));
210
+ return;
211
+ }
212
+ if (event.key.length === 1 && isCalcCharacter(event.key)) {
213
+ event.preventDefault();
214
+ // No teclado numérico o decimal é ponto; em reais, é vírgula.
215
+ setExpression((current) => current + (event.key === "." ? "," : event.key));
216
+ }
217
+ };
218
+
219
+ return (
220
+ <Popover open={open} onOpenChange={handleOpenChange}>
221
+ <PopoverAnchor asChild>
222
+ <div className="relative">
223
+ <Input
224
+ id={id}
225
+ name={name}
226
+ value={value}
227
+ onChange={(event) => onChange(maskCurrencyBR(event.target.value))}
228
+ onBlur={onBlur}
229
+ placeholder={placeholder}
230
+ disabled={disabled}
231
+ autoFocus={autoFocus}
232
+ prefix="R$"
233
+ inputMode="decimal"
234
+ className="pr-11"
235
+ />
236
+ <PopoverTrigger asChild>
237
+ <Button
238
+ type="button"
239
+ variant="ghost"
240
+ size="iconSm"
241
+ disabled={disabled}
242
+ aria-label={t("calculator.open")}
243
+ className={cn(
244
+ "absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-primary",
245
+ open && "bg-primary/15 text-primary",
246
+ )}
247
+ >
248
+ <Calculator />
249
+ </Button>
250
+ </PopoverTrigger>
251
+ </div>
252
+ </PopoverAnchor>
253
+
254
+ <PopoverContent
255
+ ref={panelRef}
256
+ align="end"
257
+ aria-label={t("calculator.title")}
258
+ onKeyDown={handleKeyDown}
259
+ // O foco fica no painel, e não na primeira tecla: assim o Enter usa o
260
+ // valor em vez de apertar o "C".
261
+ onOpenAutoFocus={(event) => {
262
+ event.preventDefault();
263
+ panelRef.current?.focus();
264
+ }}
265
+ tabIndex={-1}
266
+ className="w-72"
267
+ >
268
+ <div className="flex min-h-14 flex-col justify-between gap-1 rounded-md border border-border bg-muted/50 px-3 py-1.5 text-right">
269
+ <span className="min-h-5 break-all font-mono text-xs text-muted-foreground">
270
+ {toDisplayExpression(expression)}
271
+ </span>
272
+ {outcome.message ? (
273
+ <span className="break-words text-xs text-destructive">{outcome.message}</span>
274
+ ) : (
275
+ <span className="break-all font-mono text-base font-medium tabular-nums">
276
+ {formatMoneyBR(outcome.value ?? 0)}
277
+ </span>
278
+ )}
279
+ </div>
280
+
281
+ <div className="mt-2 grid grid-cols-4 gap-1">
282
+ {KEYS.map((key, index) => (
283
+ <Button
284
+ key={key.label ?? key.action ?? index}
285
+ type="button"
286
+ variant={key.role === "aux" ? "outline" : "secondary"}
287
+ aria-label={key.labelKey ? t(key.labelKey) : undefined}
288
+ onClick={() => press(key)}
289
+ className={cn(
290
+ "h-8 px-0 font-mono text-sm",
291
+ key.role === "operator" && "text-primary",
292
+ key.role === "aux" && "text-muted-foreground",
293
+ )}
294
+ >
295
+ {key.label ?? (key.input ? toDisplayExpression(key.input) : <Delete />)}
296
+ </Button>
297
+ ))}
298
+ </div>
299
+
300
+ {/*
301
+ A dica saiu daqui e virou o `title` do botão: num notebook de 768px o
302
+ painel inteiro não cabe na altura disponível, e o `PopoverContent`
303
+ passa a rolar — o teclado de uma calculadora com barra de rolagem é
304
+ pior que uma dica a menos.
305
+ */}
306
+ <div className="mt-2 flex gap-1">
307
+ <Button
308
+ type="button"
309
+ variant="outline"
310
+ className="h-8 flex-1"
311
+ onClick={() => setOpen(false)}
312
+ >
313
+ {t("calculator.cancel")}
314
+ </Button>
315
+ <Button
316
+ type="button"
317
+ className="h-8 flex-1"
318
+ title={t("calculator.hint")}
319
+ disabled={outcome.value === null}
320
+ onClick={apply}
321
+ >
322
+ {t("calculator.apply")}
323
+ </Button>
324
+ </div>
325
+ </PopoverContent>
326
+ </Popover>
327
+ );
328
+ };
@@ -17,6 +17,7 @@ import {
17
17
  DialogHeader,
18
18
  DialogTitle,
19
19
  Field,
20
+ HoverTip,
20
21
  Input,
21
22
  RowActions,
22
23
  } from "#core/components/ui";
@@ -157,23 +158,29 @@ export function CatalogPanel({
157
158
  cell: ({ row }) => (
158
159
  <RowActions>
159
160
  {canUpdate && (
160
- <Button
161
- variant="ghost"
162
- size="iconSm"
163
- onClick={() => openEdit(row.original)}
164
- >
165
- <Pencil className="h-4 w-4" />
166
- </Button>
161
+ <HoverTip label={t("common.edit")}>
162
+ <Button
163
+ aria-label={t("common.edit")}
164
+ variant="ghost"
165
+ size="iconSm"
166
+ onClick={() => openEdit(row.original)}
167
+ >
168
+ <Pencil className="h-4 w-4" />
169
+ </Button>
170
+ </HoverTip>
167
171
  )}
168
172
  {canDelete && (
169
- <Button
170
- variant="ghost"
171
- size="iconSm"
172
- className="text-destructive"
173
- onClick={() => removeItem(row.original)}
174
- >
175
- <Trash2 className="h-4 w-4" />
176
- </Button>
173
+ <HoverTip label={t("common.delete")}>
174
+ <Button
175
+ aria-label={t("common.delete")}
176
+ variant="ghost"
177
+ size="iconSm"
178
+ className="text-destructive"
179
+ onClick={() => removeItem(row.original)}
180
+ >
181
+ <Trash2 className="h-4 w-4" />
182
+ </Button>
183
+ </HoverTip>
177
184
  )}
178
185
  </RowActions>
179
186
  ),
@@ -18,6 +18,7 @@ import {
18
18
  DialogHeader,
19
19
  DialogTitle,
20
20
  Field,
21
+ HoverTip,
21
22
  Input,
22
23
  Label,
23
24
  RowActions,
@@ -188,23 +189,29 @@ export function GroupsPanel(): JSX.Element {
188
189
  cell: ({ row }) => (
189
190
  <RowActions>
190
191
  {hasPermission("groups:update:any") && (
191
- <Button
192
- variant="ghost"
193
- size="iconSm"
194
- onClick={() => openEdit(row.original)}
195
- >
196
- <Pencil className="h-4 w-4" />
197
- </Button>
192
+ <HoverTip label={t("common.edit")}>
193
+ <Button
194
+ aria-label={t("common.edit")}
195
+ variant="ghost"
196
+ size="iconSm"
197
+ onClick={() => openEdit(row.original)}
198
+ >
199
+ <Pencil className="h-4 w-4" />
200
+ </Button>
201
+ </HoverTip>
198
202
  )}
199
203
  {hasPermission("groups:delete:any") && (
200
- <Button
201
- variant="ghost"
202
- size="iconSm"
203
- className="text-destructive"
204
- onClick={() => remove(row.original)}
205
- >
206
- <Trash2 className="h-4 w-4" />
207
- </Button>
204
+ <HoverTip label={t("common.delete")}>
205
+ <Button
206
+ aria-label={t("common.delete")}
207
+ variant="ghost"
208
+ size="iconSm"
209
+ className="text-destructive"
210
+ onClick={() => remove(row.original)}
211
+ >
212
+ <Trash2 className="h-4 w-4" />
213
+ </Button>
214
+ </HoverTip>
208
215
  )}
209
216
  </RowActions>
210
217
  ),
@@ -16,6 +16,7 @@ import {
16
16
  DialogHeader,
17
17
  DialogTitle,
18
18
  Field,
19
+ HoverTip,
19
20
  Input,
20
21
  RowActions,
21
22
  } from "#core/components/ui";
@@ -118,23 +119,29 @@ export function PermissionsPanel(): JSX.Element {
118
119
  cell: ({ row }) => (
119
120
  <RowActions>
120
121
  {hasPermission("permissions:update:any") && (
121
- <Button
122
- variant="ghost"
123
- size="iconSm"
124
- onClick={() => openEdit(row.original)}
125
- >
126
- <Pencil className="h-4 w-4" />
127
- </Button>
122
+ <HoverTip label={t("common.edit")}>
123
+ <Button
124
+ aria-label={t("common.edit")}
125
+ variant="ghost"
126
+ size="iconSm"
127
+ onClick={() => openEdit(row.original)}
128
+ >
129
+ <Pencil className="h-4 w-4" />
130
+ </Button>
131
+ </HoverTip>
128
132
  )}
129
133
  {hasPermission("permissions:delete:any") && (
130
- <Button
131
- variant="ghost"
132
- size="iconSm"
133
- className="text-destructive"
134
- onClick={() => remove(row.original)}
135
- >
136
- <Trash2 className="h-4 w-4" />
137
- </Button>
134
+ <HoverTip label={t("common.delete")}>
135
+ <Button
136
+ aria-label={t("common.delete")}
137
+ variant="ghost"
138
+ size="iconSm"
139
+ className="text-destructive"
140
+ onClick={() => remove(row.original)}
141
+ >
142
+ <Trash2 className="h-4 w-4" />
143
+ </Button>
144
+ </HoverTip>
138
145
  )}
139
146
  </RowActions>
140
147
  ),
@@ -26,6 +26,7 @@ import {
26
26
  CreateButton,
27
27
  DataTable,
28
28
  DataTableFeatures,
29
+ HoverTip,
29
30
  RowActions,
30
31
  } from "#core/components/ui";
31
32
  import { useAuth, useI18n } from "#core/contexts";
@@ -152,74 +153,82 @@ export function UsersScreen(): JSX.Element {
152
153
  return (
153
154
  <RowActions>
154
155
  {canUpdate && (
155
- <Button
156
- variant="ghost"
157
- size="iconSm"
158
- title={t("common.edit")}
159
- onClick={() => openEdit(item)}
160
- >
161
- <Pencil className="h-4 w-4" />
162
- </Button>
156
+ <HoverTip label={t("common.edit")}>
157
+ <Button
158
+ variant="ghost"
159
+ size="iconSm"
160
+ aria-label={t("common.edit")}
161
+ onClick={() => openEdit(item)}
162
+ >
163
+ <Pencil className="h-4 w-4" />
164
+ </Button>
165
+ </HoverTip>
163
166
  )}
164
167
  {canInvite && (
165
- <Button
166
- variant="ghost"
167
- size="iconSm"
168
- title={t("users.sendFirstAccessLink")}
169
- onClick={() => {
170
- void confirm({
171
- title: t("users.sendFirstAccessLink"),
172
- description: item.email,
173
- confirmLabel: t("common.send"),
174
- }).then((ok) => {
175
- if (ok) {
176
- void u.sendFirstAccessLink(item.id);
177
- }
178
- });
179
- }}
180
- >
181
- <MailPlus className="h-4 w-4" />
182
- </Button>
168
+ <HoverTip label={t("users.sendFirstAccessLink")}>
169
+ <Button
170
+ variant="ghost"
171
+ size="iconSm"
172
+ aria-label={t("users.sendFirstAccessLink")}
173
+ onClick={() => {
174
+ void confirm({
175
+ title: t("users.sendFirstAccessLink"),
176
+ description: item.email,
177
+ confirmLabel: t("common.send"),
178
+ }).then((ok) => {
179
+ if (ok) {
180
+ void u.sendFirstAccessLink(item.id);
181
+ }
182
+ });
183
+ }}
184
+ >
185
+ <MailPlus className="h-4 w-4" />
186
+ </Button>
187
+ </HoverTip>
183
188
  )}
184
189
  {canReset && (
185
- <Button
186
- variant="ghost"
187
- size="iconSm"
188
- title={t("users.sendPasswordResetLink")}
189
- onClick={() => {
190
- void confirm({
191
- title: t("users.sendPasswordResetLink"),
192
- description: item.email,
193
- confirmLabel: t("common.send"),
194
- }).then((ok) => {
195
- if (ok) {
196
- void u.sendPasswordResetLink(item.id);
197
- }
198
- });
199
- }}
200
- >
201
- <KeyRound className="h-4 w-4" />
202
- </Button>
190
+ <HoverTip label={t("users.sendPasswordResetLink")}>
191
+ <Button
192
+ variant="ghost"
193
+ size="iconSm"
194
+ aria-label={t("users.sendPasswordResetLink")}
195
+ onClick={() => {
196
+ void confirm({
197
+ title: t("users.sendPasswordResetLink"),
198
+ description: item.email,
199
+ confirmLabel: t("common.send"),
200
+ }).then((ok) => {
201
+ if (ok) {
202
+ void u.sendPasswordResetLink(item.id);
203
+ }
204
+ });
205
+ }}
206
+ >
207
+ <KeyRound className="h-4 w-4" />
208
+ </Button>
209
+ </HoverTip>
203
210
  )}
204
211
  {canReset2fa && item.twoFactorEnabled && (
205
- <Button
206
- variant="ghost"
207
- size="iconSm"
208
- title={t("users.resetTwoFactor")}
209
- onClick={() => {
210
- void confirm({
211
- title: t("users.resetTwoFactor"),
212
- description: item.name,
213
- destructive: true,
214
- }).then((ok) => {
215
- if (ok) {
216
- void u.resetTwoFactor(item.id);
217
- }
218
- });
219
- }}
220
- >
221
- <ShieldOff className="h-4 w-4" />
222
- </Button>
212
+ <HoverTip label={t("users.resetTwoFactor")}>
213
+ <Button
214
+ variant="ghost"
215
+ size="iconSm"
216
+ aria-label={t("users.resetTwoFactor")}
217
+ onClick={() => {
218
+ void confirm({
219
+ title: t("users.resetTwoFactor"),
220
+ description: item.name,
221
+ destructive: true,
222
+ }).then((ok) => {
223
+ if (ok) {
224
+ void u.resetTwoFactor(item.id);
225
+ }
226
+ });
227
+ }}
228
+ >
229
+ <ShieldOff className="h-4 w-4" />
230
+ </Button>
231
+ </HoverTip>
223
232
  )}
224
233
  {canDeactivate && (
225
234
  <Button
@@ -238,26 +247,28 @@ export function UsersScreen(): JSX.Element {
238
247
  </Button>
239
248
  )}
240
249
  {canDelete && (
241
- <Button
242
- variant="ghost"
243
- size="iconSm"
244
- className="text-destructive"
245
- title={t("common.delete")}
246
- onClick={() => {
247
- void confirm({
248
- title: t("common.delete"),
249
- description: item.name,
250
- confirmLabel: t("common.delete"),
251
- destructive: true,
252
- }).then((ok) => {
253
- if (ok) {
254
- void u.remove(item.id);
255
- }
256
- });
257
- }}
258
- >
259
- <Trash2 className="h-4 w-4" />
260
- </Button>
250
+ <HoverTip label={t("common.delete")}>
251
+ <Button
252
+ variant="ghost"
253
+ size="iconSm"
254
+ className="text-destructive"
255
+ aria-label={t("common.delete")}
256
+ onClick={() => {
257
+ void confirm({
258
+ title: t("common.delete"),
259
+ description: item.name,
260
+ confirmLabel: t("common.delete"),
261
+ destructive: true,
262
+ }).then((ok) => {
263
+ if (ok) {
264
+ void u.remove(item.id);
265
+ }
266
+ });
267
+ }}
268
+ >
269
+ <Trash2 className="h-4 w-4" />
270
+ </Button>
271
+ </HoverTip>
261
272
  )}
262
273
  </RowActions>
263
274
  );
@@ -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,