rl-core-front 0.16.2 → 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.2",
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
+ };
@@ -253,6 +253,110 @@ export const maskDay = (raw: string, locale: string, previous = ""): string => {
253
253
  return saida.join("/");
254
254
  };
255
255
 
256
+ /** Onde está o próximo dígito a partir de `posicao` — barra não se digita. */
257
+ const proximoDigito = (texto: string, posicao: number): number => {
258
+ let atual = posicao;
259
+
260
+ while (atual < texto.length && texto[atual] === "/") {
261
+ atual += 1;
262
+ }
263
+
264
+ return atual;
265
+ };
266
+
267
+ /**
268
+ * Um dígito digitado **sobre** uma data já escrita: troca o de baixo do cursor.
269
+ *
270
+ * É o que faz o cursor parado no começo de `01/01/2026` servir para algo. Sem
271
+ * isto, a tecla se enfiaria no texto: `3` no começo produziria `301/01/2026`,
272
+ * que a máscara — que lê campo a campo — remonta como `30/10/1202`, uma data
273
+ * que ninguém digitou.
274
+ *
275
+ * Trabalha **por campo**, e não por caractere do texto: é o campo que tem teto
276
+ * (dia até 31, mês até 12) e é ele que diz quando a tecla seguinte muda de
277
+ * lugar. Duas saídas, conforme o dígito caiba ou não:
278
+ *
279
+ * - cabe: é correção, e o resto do campo fica de pé — `1` sobre o `2` de
280
+ * `2026` dá `1026`, e não um ano recomeçado do zero;
281
+ * - não cabe: o campo recomeça daquele dígito, como se estivesse sendo
282
+ * digitado agora — `9` sobre o dia `11` dá `09`, porque 9 não começa dia
283
+ * nenhum, e o cursor já passa para o mês.
284
+ *
285
+ * A posição devolvida é a do próximo dígito a digitar, pulando a barra: quem
286
+ * teclar `3`, `1`, `0`, `8` escreve 31/08 sem tocar em seta nenhuma. Campo que
287
+ * ficou pela metade (o mês `1`, que ainda pode virar 10, 11 ou 12) mantém o
288
+ * cursor nele, esperando o segundo dígito.
289
+ */
290
+ export const overtypeDay = (
291
+ texto: string,
292
+ posicao: number,
293
+ digito: string,
294
+ locale: string,
295
+ ): { texto: string; posicao: number } => {
296
+ const order = dayFieldOrder(locale);
297
+ const campos = texto.split("/");
298
+
299
+ // Só sobre uma data inteira. Meia data é o campo sendo preenchido, e ali a
300
+ // digitação normal já escreve no fim.
301
+ if (campos.length !== order.length) {
302
+ return { texto, posicao };
303
+ }
304
+
305
+ /*
306
+ Em que campo o cursor está, e em que dígito dele.
307
+
308
+ O cursor em cima da barra conta como o começo do campo seguinte — é onde
309
+ ele está depois de fechar o dia. E o cursor no fim de um campo que ainda
310
+ não está cheio continua nesse campo: é o `1` do mês esperando o `2`.
311
+ */
312
+ let base = 0;
313
+ let alvo = -1;
314
+ let offset = 0;
315
+
316
+ for (let indice = 0; indice < campos.length; indice += 1) {
317
+ const fim = base + campos[indice].length;
318
+ const cheio = campos[indice].length >= DAY_FIELD_SIZES[order[indice]];
319
+
320
+ if (posicao < fim || (posicao === fim && !cheio)) {
321
+ alvo = indice;
322
+ offset = Math.max(0, posicao - base);
323
+ break;
324
+ }
325
+
326
+ base = fim + 1;
327
+ }
328
+
329
+ // Depois do último dígito não há o que sobrescrever, e acrescentar faria a
330
+ // data crescer para além do formato.
331
+ if (alvo === -1) {
332
+ return { texto, posicao };
333
+ }
334
+
335
+ const tamanho = DAY_FIELD_SIZES[order[alvo]];
336
+ const max = DAY_FIELD_MAX[order[alvo]];
337
+ const campo = campos[alvo];
338
+ const trocado = `${campo.slice(0, offset)}${digito}${campo.slice(offset + 1)}`;
339
+ const cabe =
340
+ trocado.length <= tamanho && Number(trocado) >= 1 && Number(trocado) <= max;
341
+ const valor = cabe ? trocado : fitDayField(digito, max, tamanho, true).value;
342
+ const novo = campos
343
+ .map((atual, indice) => (indice === alvo ? valor : atual))
344
+ .join("/");
345
+
346
+ // Quantos dígitos do campo a tecla consumiu: um, quando corrigiu; o campo
347
+ // todo, quando ele recomeçou (o `9` que já vira `09`).
348
+ const consumido = cabe ? offset + 1 : valor.length;
349
+ const parou = base + consumido;
350
+
351
+ return {
352
+ texto: novo,
353
+ posicao:
354
+ consumido >= valor.length && valor.length === tamanho
355
+ ? proximoDigito(novo, parou)
356
+ : parou,
357
+ };
358
+ };
359
+
256
360
  /**
257
361
  * O que foi digitado vira `"yyyy-MM-dd"`, ou `null` enquanto não for uma data.
258
362
  *
@@ -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 {
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { Calendar, ChevronLeft, ChevronRight } from "lucide-react";
4
- import type { JSX } from "react";
5
- import { useState } from "react";
4
+ import type { JSX, KeyboardEvent } from "react";
5
+ import { useLayoutEffect, useRef, useState } from "react";
6
6
 
7
7
  import {
8
8
  addMonths,
@@ -14,6 +14,7 @@ import {
14
14
  maskDay,
15
15
  monthGrid,
16
16
  monthLabel,
17
+ overtypeDay,
17
18
  parseTypedDay,
18
19
  today,
19
20
  toIsoDay,
@@ -55,10 +56,12 @@ export interface DatePickerProps {
55
56
  * precisa navegar meses no calendário para dizê-la. O calendário continua ali,
56
57
  * no ícone — é o caminho de quem procura o dia, não de quem já o conhece.
57
58
  *
58
- * Focar o campo marca a data inteira, e por isso a digitação sempre começa
59
- * pelo dia. Com o cursor solto no fim do ano onde o navegador o deixa
60
- * escrever outra data exigia apagar dez caracteres primeiro, e um dígito
61
- * digitado ali entrava no meio do ano.
59
+ * Focar o campo põe o cursor **antes do dia**, e o dígito digitado sobrescreve
60
+ * o que está embaixo dele em vez de empurrar o resto: `3` sobre `01/01/2026`
61
+ * `31/01/2026` com o cursor no mês. Com o cursor solto no fim do ano —
62
+ * onde o navegador o deixa trocar a data exigia apagar dez caracteres
63
+ * primeiro; marcar tudo resolvia isso, mas obrigava a redigitar mês e ano para
64
+ * corrigir só o dia.
62
65
  */
63
66
  export function DatePicker({
64
67
  value,
@@ -81,23 +84,37 @@ export function DatePicker({
81
84
  const selected = fromIsoDay(value);
82
85
  const [month, setMonth] = useState<Date>(() => selected ?? today());
83
86
 
87
+ const inputRef = useRef<HTMLInputElement | null>(null);
88
+ /*
89
+ Onde o cursor deve ficar depois do próximo render.
90
+
91
+ O input é controlado, então quem escreve o valor é o React — e ele repõe o
92
+ cursor no fim. A posição precisa ser reaplicada depois da pintura, e por
93
+ isso vive num ref: em estado, ela causaria um render só para dizer onde o
94
+ cursor está.
95
+ */
96
+ const caret = useRef<number | null>(null);
97
+
98
+ useLayoutEffect(() => {
99
+ if (caret.current === null || !inputRef.current) {
100
+ return;
101
+ }
102
+
103
+ inputRef.current.setSelectionRange(caret.current, caret.current);
104
+ caret.current = null;
105
+ });
106
+
84
107
  const pickDay = (day: Date): void => {
85
108
  onChange(toIsoDay(day));
86
109
  setTyped(null);
87
110
  setOpen(false);
88
111
  };
89
112
 
90
- /**
91
- * Cada tecla: a máscara põe as barras, e a data sai assim que existir.
92
- *
93
- * O ano em branco é o do dia que está no campo — ou o de hoje, quando não há
94
- * nenhum. Campo esvaziado limpa o valor: apagar é uma escolha, não um
95
- * rascunho pela metade.
96
- */
97
- const digitar = (raw: string): void => {
98
- // O texto que estava lá antes: é ele que diz se a tecla foi um dígito novo
99
- // ou um apagar — e o campo se comporta diferente nos dois casos.
100
- const texto = maskDay(raw, locale, typed ?? (value ? formatDay(value, locale) : ""));
113
+ /** O que o campo mostra agora: o rascunho, ou a data gravada. */
114
+ const escrito = (): string => typed ?? (value ? formatDay(value, locale) : "");
115
+
116
+ /** O texto vira rascunho e, se for uma data, valor. */
117
+ const aplicar = (texto: string): void => {
101
118
  setTyped(texto);
102
119
 
103
120
  if (texto === "") {
@@ -118,6 +135,50 @@ export function DatePicker({
118
135
  }
119
136
  };
120
137
 
138
+ /**
139
+ * Cada tecla: a máscara põe as barras, e a data sai assim que existir.
140
+ *
141
+ * O ano em branco é o do dia que está no campo — ou o de hoje, quando não há
142
+ * nenhum. Campo esvaziado limpa o valor: apagar é uma escolha, não um
143
+ * rascunho pela metade.
144
+ */
145
+ const digitar = (raw: string): void => {
146
+ // O texto que estava lá antes: é ele que diz se a tecla foi um dígito novo
147
+ // ou um apagar — e o campo se comporta diferente nos dois casos.
148
+ aplicar(maskDay(raw, locale, escrito()));
149
+ };
150
+
151
+ /**
152
+ * Dígito sobre uma data já escrita: troca o de baixo do cursor.
153
+ *
154
+ * Só quando a data está completa e nada está selecionado. Data pela metade é
155
+ * o campo sendo preenchido, e ali o cursor já está no fim; com um trecho
156
+ * marcado, digitar substitui o trecho, que é o que qualquer campo de texto
157
+ * faz e ninguém espera diferente.
158
+ */
159
+ const sobrescrever = (event: KeyboardEvent<HTMLInputElement>): void => {
160
+ const campo = event.currentTarget;
161
+ const inicio = campo.selectionStart ?? 0;
162
+
163
+ if (
164
+ !/^\d$/.test(event.key) ||
165
+ event.ctrlKey ||
166
+ event.metaKey ||
167
+ event.altKey ||
168
+ campo.value.length < dayPlaceholder(locale).length ||
169
+ (campo.selectionEnd ?? inicio) !== inicio
170
+ ) {
171
+ return;
172
+ }
173
+
174
+ event.preventDefault();
175
+
176
+ const { texto, posicao } = overtypeDay(campo.value, inicio, event.key, locale);
177
+
178
+ caret.current = posicao;
179
+ aplicar(texto);
180
+ };
181
+
121
182
  const dayClass = (day: Date): string => {
122
183
  if (selected && isSameDay(day, selected)) {
123
184
  return "bg-primary text-primary-foreground font-semibold";
@@ -142,18 +203,20 @@ export function DatePicker({
142
203
  )}
143
204
  >
144
205
  <input
206
+ ref={inputRef}
145
207
  aria-label={label}
146
- value={typed ?? (value ? formatDay(value, locale) : "")}
208
+ value={escrito()}
147
209
  disabled={disabled}
148
210
  // Só dígitos, e o teclado do celular abre no numérico: a máscara é
149
211
  // quem escreve as barras.
150
212
  inputMode="numeric"
151
213
  placeholder={placeholder ?? dayPlaceholder(locale)}
152
214
  onChange={(event) => digitar(event.target.value)}
153
- // Chegou o foco, a data inteira fica marcada: o próximo dígito
154
- // reescreve a data do dia em diante, em vez de se enfiar no meio
155
- // do ano.
156
- onFocus={(event) => event.currentTarget.select()}
215
+ onKeyDown={sobrescrever}
216
+ // Chegou o foco, o cursor vai para antes do dia: é por ele que se
217
+ // começa a escrever uma data, e o navegador deixaria o cursor no
218
+ // fim do ano.
219
+ onFocus={(event) => event.currentTarget.setSelectionRange(0, 0)}
157
220
  // O clique é o nosso, e não o do navegador: o dele põe o cursor
158
221
  // onde se clicou e desfaz, no `mouseup`, a seleção que o foco
159
222
  // acabou de fazer. Só no primeiro clique — dentro do campo já
@@ -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,