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 +1 -1
- package/src/_utils/calc.ts +303 -0
- package/src/_utils/format.ts +21 -0
- package/src/components/app-shell.tsx +10 -7
- package/src/components/ui/button.tsx +35 -2
- package/src/components/ui/hover-tip.tsx +79 -0
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/money-input.tsx +328 -0
- package/src/features/rbac/panels/catalog-panel.tsx +22 -15
- package/src/features/rbac/panels/groups-panel.tsx +22 -15
- package/src/features/rbac/panels/permissions-panel.tsx +22 -15
- package/src/features/users/users-screen.tsx +93 -82
- 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/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 {
|
|
@@ -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
|
-
(
|
|
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
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
<
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
<
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
<
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
<
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
);
|
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",
|