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 +1 -1
- package/src/_utils/calc.ts +303 -0
- package/src/_utils/calendar.ts +104 -0
- package/src/_utils/format.ts +21 -0
- package/src/components/app-shell.tsx +10 -7
- package/src/components/ui/date-picker.tsx +85 -22
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/money-input.tsx +314 -0
- package/src/i18n/messages/en.ts +19 -0
- package/src/i18n/messages/pt.ts +19 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -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
|
+
};
|
package/src/_utils/calendar.ts
CHANGED
|
@@ -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
|
*
|
package/src/_utils/format.ts
CHANGED
|
@@ -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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
+
* dá `31/01/2026` com o cursor já 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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 já 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={
|
|
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
|
-
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
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
|
+
};
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -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",
|
package/src/i18n/messages/pt.ts
CHANGED
|
@@ -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",
|