rl-core-front 0.19.1 → 0.19.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/_services/api/schema.d.ts +6 -1
- package/src/_utils/calc.ts +116 -12
- package/src/_utils/format.ts +57 -0
- package/src/_utils/text.ts +25 -0
- package/src/components/ui/combobox.tsx +46 -12
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/money-input.tsx +136 -27
- package/src/components/ui/searchable-select.tsx +69 -0
- package/src/features/notifications/components/notification-item.tsx +58 -33
- package/src/features/notifications/notifications-center.tsx +108 -27
- package/src/i18n/messages/en.ts +3 -0
- package/src/i18n/messages/pt.ts +3 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1581,6 +1581,11 @@ export interface components {
|
|
|
1581
1581
|
* @example /usuarios
|
|
1582
1582
|
*/
|
|
1583
1583
|
route?: string | null;
|
|
1584
|
+
/**
|
|
1585
|
+
* @description Módulo de origem, que agrupa o sino. Nulo quando veio do próprio sistema.
|
|
1586
|
+
* @example Contas
|
|
1587
|
+
*/
|
|
1588
|
+
source?: string | null;
|
|
1584
1589
|
read: boolean;
|
|
1585
1590
|
/** Format: date-time */
|
|
1586
1591
|
createdAt: string;
|
|
@@ -1605,7 +1610,7 @@ export interface components {
|
|
|
1605
1610
|
body: string;
|
|
1606
1611
|
type: components["schemas"]["NotificationType"];
|
|
1607
1612
|
targets: components["schemas"]["NotificationTargetCommand"][];
|
|
1608
|
-
/** @description Dado extra. `route` é usado pelo cliente para navegar. */
|
|
1613
|
+
/** @description Dado extra. `route` é usado pelo cliente para navegar; `source` é o módulo de origem, que agrupa o sino. */
|
|
1609
1614
|
payload?: Record<string, never>;
|
|
1610
1615
|
};
|
|
1611
1616
|
RequestExportCommand: {
|
package/src/_utils/calc.ts
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* enquanto este parser recusa o que não entende em vez de tentar adivinhar.
|
|
9
9
|
*
|
|
10
10
|
* O que ele entende: `+ - * / %`, parênteses, sinal negativo, e número no
|
|
11
|
-
* formato brasileiro (ponto de milhar, vírgula decimal)
|
|
11
|
+
* formato brasileiro (ponto de milhar, vírgula decimal) — com os dígitos
|
|
12
|
+
* soltos lidos em centavos, como o campo de dinheiro faz (`4250` é 42,50).
|
|
12
13
|
*/
|
|
13
14
|
|
|
15
|
+
import { maskCurrencyBR } from "#core/_utils/format";
|
|
16
|
+
|
|
14
17
|
/**
|
|
15
18
|
* Por que a conta não fechou. É código e não frase pronta porque a mensagem é
|
|
16
19
|
* texto de tela: quem traduz é o componente, com o dicionário de quem está
|
|
@@ -83,15 +86,99 @@ const SYMBOL_TO_GLYPH = new Map<string, string>(OPERATOR_GLYPHS);
|
|
|
83
86
|
|
|
84
87
|
const SYMBOLS = "+-*/%()";
|
|
85
88
|
|
|
89
|
+
/** O desenho de uma tecla: `*` vira `×`; o resto fica como é. */
|
|
90
|
+
export const glyphOf = (symbol: string): string => SYMBOL_TO_GLYPH.get(symbol) ?? symbol;
|
|
91
|
+
|
|
92
|
+
/** Um pedaço da conta: um número (e se ele é dinheiro ou não) ou um símbolo. */
|
|
93
|
+
interface Run {
|
|
94
|
+
kind: "number" | "symbol";
|
|
95
|
+
text: string;
|
|
96
|
+
/** Só em número: quantidade ou porcentagem, e não dinheiro — ver `toNumber`. */
|
|
97
|
+
plain: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
/**
|
|
87
|
-
* A conta
|
|
101
|
+
* A conta em pedaços, com cada número já sabendo se é dinheiro.
|
|
88
102
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
103
|
+
* É a mesma pergunta que o tokenizador faz — o que vem depois de `×`/`÷` e
|
|
104
|
+
* o que vem antes de `%` não é dinheiro —, respondida sobre o texto cru para
|
|
105
|
+
* o visor e a normalização usarem sem avaliar a conta (que pode estar pela
|
|
106
|
+
* metade). O que não é número nem símbolo é descartado.
|
|
107
|
+
*/
|
|
108
|
+
const splitRuns = (source: string): Run[] => {
|
|
109
|
+
const runs: Run[] = [];
|
|
110
|
+
let index = 0;
|
|
111
|
+
|
|
112
|
+
while (index < source.length) {
|
|
113
|
+
const character: string = source[index];
|
|
114
|
+
|
|
115
|
+
if (NUMBER_PART.test(character)) {
|
|
116
|
+
let end: number = index;
|
|
117
|
+
while (end < source.length && NUMBER_PART.test(source[end])) {
|
|
118
|
+
end += 1;
|
|
119
|
+
}
|
|
120
|
+
runs.push({ kind: "number", text: source.slice(index, end), plain: false });
|
|
121
|
+
index = end;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const symbol: string = GLYPH_TO_SYMBOL.get(character) ?? character;
|
|
126
|
+
if (SYMBOLS.includes(symbol)) {
|
|
127
|
+
runs.push({ kind: "symbol", text: symbol, plain: false });
|
|
128
|
+
}
|
|
129
|
+
index += 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
runs.forEach((run, position) => {
|
|
133
|
+
if (run.kind !== "number") {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const previous: Run | undefined = runs[position - 1];
|
|
137
|
+
const next: Run | undefined = runs[position + 1];
|
|
138
|
+
|
|
139
|
+
run.plain =
|
|
140
|
+
(previous?.kind === "symbol" && (previous.text === "*" || previous.text === "/")) ||
|
|
141
|
+
(next?.kind === "symbol" && next.text === "%");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return runs;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* O texto do visor, como a pessoa o vê e edita, na forma que a conta lê.
|
|
149
|
+
*
|
|
150
|
+
* O visor é um campo de texto que mostra dinheiro formatado (`44,90`), e a
|
|
151
|
+
* pessoa digita, apaga e cola nele. O que sai daqui é a forma canônica: os
|
|
152
|
+
* símbolos em vez dos desenhos, o dinheiro só em dígitos (`4490` — a vírgula
|
|
153
|
+
* e o ponto são da máscara, e o que a pessoa digitou de ponto ou vírgula num
|
|
154
|
+
* valor em reais é descartado, como o campo faz), e a quantidade e a
|
|
155
|
+
* porcentagem como vieram, que ali `1,5` é literal.
|
|
156
|
+
*/
|
|
157
|
+
export const normalizeExpression = (display: string): string =>
|
|
158
|
+
splitRuns(display)
|
|
159
|
+
.map((run) =>
|
|
160
|
+
run.kind === "number" && !run.plain ? run.text.replace(/\D/g, "") : run.text,
|
|
161
|
+
)
|
|
162
|
+
.join("");
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A conta como se lê na tela: `4490*2+10%` vira `44,90×2+10%`.
|
|
166
|
+
*
|
|
167
|
+
* O dinheiro sai formatado como o campo o mostra — `4490` é 44,90 —, para
|
|
168
|
+
* a pessoa ver o mesmo que a conta vai ler; a quantidade e a porcentagem
|
|
169
|
+
* ficam como estão. Mora aqui, junto do que faz o caminho de volta
|
|
170
|
+
* (`normalizeExpression`), e não no componente: são a mesma correspondência,
|
|
171
|
+
* e separadas elas saem de sincronia calada.
|
|
91
172
|
*/
|
|
92
173
|
export const toDisplayExpression = (source: string): string =>
|
|
93
|
-
|
|
94
|
-
.map((
|
|
174
|
+
splitRuns(source)
|
|
175
|
+
.map((run) => {
|
|
176
|
+
if (run.kind === "symbol") {
|
|
177
|
+
return SYMBOL_TO_GLYPH.get(run.text) ?? run.text;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return run.plain ? run.text : maskCurrencyBR(run.text);
|
|
181
|
+
})
|
|
95
182
|
.join("");
|
|
96
183
|
|
|
97
184
|
/**
|
|
@@ -109,14 +196,25 @@ export const isCalcCharacter = (character: string): boolean =>
|
|
|
109
196
|
/**
|
|
110
197
|
* Número brasileiro para `number`.
|
|
111
198
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* vírgula,
|
|
115
|
-
*
|
|
199
|
+
* **Dígitos soltos são centavos**, como no campo: quem digita `4250` no campo
|
|
200
|
+
* vê 42,50, e a calculadora lendo 4.250,00 era a mesma tecla dando dois
|
|
201
|
+
* valores. A vírgula, quando vem, manda — `42,50` é literal. Duas exceções,
|
|
202
|
+
* que são o que não é dinheiro (`plain`): a porcentagem (`10%` são dez por
|
|
203
|
+
* cento, não 0,10%) e o que vem depois de `×` ou `÷` — quantidade, não
|
|
204
|
+
* valor: `4250×3` são três de 42,50, e `100,00÷4` é dividir em quatro.
|
|
205
|
+
*
|
|
206
|
+
* O ponto sozinho é milhar: `1.200` é mil e duzentos para quem digita em
|
|
207
|
+
* reais, e 1,2 para o `Number`. A regra é a do país — havendo vírgula, todo
|
|
208
|
+
* ponto é milhar; sem vírgula, o ponto ainda é milhar quando separa exatamente
|
|
209
|
+
* três dígitos no fim, ou quando aparece mais de uma vez.
|
|
116
210
|
*/
|
|
117
|
-
const toNumber = (raw: string): number => {
|
|
211
|
+
const toNumber = (raw: string, plain: boolean): number => {
|
|
118
212
|
let text: string = raw;
|
|
119
213
|
|
|
214
|
+
if (/^\d+$/.test(text) && !plain) {
|
|
215
|
+
return Number(text) / 100;
|
|
216
|
+
}
|
|
217
|
+
|
|
120
218
|
if (text.includes(",")) {
|
|
121
219
|
text = text.replace(/\./g, "").replace(/,/g, ".");
|
|
122
220
|
} else if ((text.match(/\./g) ?? []).length > 1 || /\d\.\d{3}$/.test(text)) {
|
|
@@ -148,7 +246,13 @@ const tokenize = (source: string): Token[] => {
|
|
|
148
246
|
while (end < source.length && NUMBER_PART.test(source[end])) {
|
|
149
247
|
end += 1;
|
|
150
248
|
}
|
|
151
|
-
|
|
249
|
+
// O `%` logo depois, ou o `×`/`÷` logo antes, mudam a leitura do
|
|
250
|
+
// número — ver `toNumber`.
|
|
251
|
+
const previous: Token | undefined = tokens[tokens.length - 1];
|
|
252
|
+
const plain: boolean =
|
|
253
|
+
/^\s*%/.test(source.slice(end)) ||
|
|
254
|
+
(previous?.type === "*" || previous?.type === "/");
|
|
255
|
+
tokens.push({ type: "number", value: toNumber(source.slice(index, end), plain) });
|
|
152
256
|
index = end;
|
|
153
257
|
continue;
|
|
154
258
|
}
|
package/src/_utils/format.ts
CHANGED
|
@@ -25,6 +25,63 @@ export function formatDateTime(value: string | Date, locale: string): string {
|
|
|
25
25
|
}).format(date);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const MINUTE_MS = 60_000;
|
|
29
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
30
|
+
|
|
31
|
+
const sameDay = (a: Date, b: Date): boolean =>
|
|
32
|
+
a.getFullYear() === b.getFullYear() &&
|
|
33
|
+
a.getMonth() === b.getMonth() &&
|
|
34
|
+
a.getDate() === b.getDate();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Há quanto tempo, do jeito que uma caixa de entrada mostra: "agora",
|
|
38
|
+
* "há 6 min", "há 2 h" no mesmo dia, "ontem, 18:02" e, antes disso, só a data
|
|
39
|
+
* curta — o que importa num aviso de três dias atrás é *qual* dia, não a hora.
|
|
40
|
+
*
|
|
41
|
+
* `now` é parâmetro para o teste fixar o relógio; em uso normal fica o padrão.
|
|
42
|
+
*/
|
|
43
|
+
export function formatRelativeTime(
|
|
44
|
+
value: string | Date,
|
|
45
|
+
locale: string,
|
|
46
|
+
now: Date = new Date(),
|
|
47
|
+
): string {
|
|
48
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
49
|
+
if (Number.isNaN(date.getTime())) {
|
|
50
|
+
return String(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const relative = new Intl.RelativeTimeFormat(locale, {
|
|
54
|
+
numeric: "auto",
|
|
55
|
+
style: "short",
|
|
56
|
+
});
|
|
57
|
+
const elapsed = now.getTime() - date.getTime();
|
|
58
|
+
|
|
59
|
+
if (elapsed < MINUTE_MS) {
|
|
60
|
+
return relative.format(0, "second");
|
|
61
|
+
}
|
|
62
|
+
if (elapsed < HOUR_MS) {
|
|
63
|
+
return relative.format(-Math.floor(elapsed / MINUTE_MS), "minute");
|
|
64
|
+
}
|
|
65
|
+
if (sameDay(date, now)) {
|
|
66
|
+
return relative.format(-Math.floor(elapsed / HOUR_MS), "hour");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const yesterday = new Date(now);
|
|
70
|
+
yesterday.setDate(now.getDate() - 1);
|
|
71
|
+
if (sameDay(date, yesterday)) {
|
|
72
|
+
const time = new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(
|
|
73
|
+
date,
|
|
74
|
+
);
|
|
75
|
+
return `${relative.format(-1, "day")}, ${time}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return new Intl.DateTimeFormat(locale, {
|
|
79
|
+
day: "2-digit",
|
|
80
|
+
month: "2-digit",
|
|
81
|
+
...(date.getFullYear() !== now.getFullYear() && { year: "numeric" }),
|
|
82
|
+
}).format(date);
|
|
83
|
+
}
|
|
84
|
+
|
|
28
85
|
/** Aplica máscara de telefone BR enquanto digita: (00) 00000-0000 */
|
|
29
86
|
export function maskPhoneBR(value: string): string {
|
|
30
87
|
const d = value.replace(/\D/g, "").slice(0, 11);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O texto pronto para comparar: minúsculo e sem acento.
|
|
3
|
+
*
|
|
4
|
+
* `"Óleo"` vira `"oleo"` — é o que faz digitar `oleo` achar "Óleo do motor"
|
|
5
|
+
* num select. O banco já compara assim (collation *accent-insensitive*); no
|
|
6
|
+
* navegador, `includes` compara byte a byte, e o acento separava o que a
|
|
7
|
+
* pessoa digitou do que estava cadastrado.
|
|
8
|
+
*
|
|
9
|
+
* `NFD` separa a letra do acento, e a faixa `\p{Diacritic}` é o acento —
|
|
10
|
+
* removê-la deixa a letra. Vale para qualquer alfabeto latino, não só o
|
|
11
|
+
* nosso.
|
|
12
|
+
*/
|
|
13
|
+
export const normalizeText = (text: string): string =>
|
|
14
|
+
text
|
|
15
|
+
.normalize("NFD")
|
|
16
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
17
|
+
.toLowerCase();
|
|
18
|
+
|
|
19
|
+
/** `text` contém `term`, ignorando acento e caixa. Termo vazio casa com tudo. */
|
|
20
|
+
export const matchesText = (text: string, term: string): boolean =>
|
|
21
|
+
normalizeText(text).includes(normalizeText(term.trim()));
|
|
22
|
+
|
|
23
|
+
/** Os dois são o mesmo nome, ignorando acento, caixa e espaços nas pontas. */
|
|
24
|
+
export const sameText = (a: string, b: string): boolean =>
|
|
25
|
+
normalizeText(a.trim()) === normalizeText(b.trim());
|
|
@@ -4,6 +4,7 @@ import { Check, ChevronDown, Plus, X } from "lucide-react";
|
|
|
4
4
|
import type { JSX } from "react";
|
|
5
5
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
6
6
|
|
|
7
|
+
import { matchesText, sameText } from "#core/_utils/text";
|
|
7
8
|
import { Badge } from "#core/components/ui/badge";
|
|
8
9
|
import { Button } from "#core/components/ui/button";
|
|
9
10
|
import {
|
|
@@ -46,8 +47,17 @@ export interface ComboboxProps {
|
|
|
46
47
|
* sabe se isso vira POST ou só um valor no formulário.
|
|
47
48
|
*/
|
|
48
49
|
creatable?: boolean;
|
|
49
|
-
/**
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Chamado com o texto digitado, já sem espaços nas pontas.
|
|
52
|
+
*
|
|
53
|
+
* Pode devolver o id do que criou — direto ou em promessa. Quando devolve,
|
|
54
|
+
* o item novo fica selecionado (no múltiplo, marcado junto aos outros):
|
|
55
|
+
* quem digitou "Kronos" e mandou criar quer a despesa na Kronos, não a
|
|
56
|
+
* lista fechada e o campo vazio. Quem não devolve nada, como o campo que
|
|
57
|
+
* só guarda o texto no formulário, continua como antes. Erro é de quem
|
|
58
|
+
* cria — o toast é dele; aqui só não se seleciona nada.
|
|
59
|
+
*/
|
|
60
|
+
onCreate?: (text: string) => CreateResult | Promise<CreateResult>;
|
|
51
61
|
/** Texto da linha de criação. Ausente, `Criar "{text}"`. */
|
|
52
62
|
createLabel?: (text: string) => string;
|
|
53
63
|
/**
|
|
@@ -64,6 +74,9 @@ export interface ComboboxProps {
|
|
|
64
74
|
idleCloseMs?: number;
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
/** O que o `onCreate` devolve: o id do item criado, ou nada. */
|
|
78
|
+
export type CreateResult = string | null | undefined | void;
|
|
79
|
+
|
|
67
80
|
/** Id da linha de criação na lista apontável — nenhuma opção real o usa. */
|
|
68
81
|
const CREATE_ID = "\u0000create";
|
|
69
82
|
|
|
@@ -160,23 +173,24 @@ export function Combobox({
|
|
|
160
173
|
[options, value],
|
|
161
174
|
);
|
|
162
175
|
|
|
176
|
+
/*
|
|
177
|
+
Sem acento e sem caixa, dos dois lados: digitar "oleo" acha "Óleo do
|
|
178
|
+
motor". É como o banco já compara — e o select, que filtra no navegador,
|
|
179
|
+
não podia ser mais exigente que ele.
|
|
180
|
+
*/
|
|
163
181
|
const termo = search.trim();
|
|
164
182
|
const filtradas = useMemo(
|
|
165
|
-
() =>
|
|
166
|
-
options.filter((option) =>
|
|
167
|
-
option.name.toLowerCase().includes(termo.toLowerCase()),
|
|
168
|
-
),
|
|
183
|
+
() => options.filter((option) => matchesText(option.name, termo)),
|
|
169
184
|
[options, termo],
|
|
170
185
|
);
|
|
171
186
|
|
|
172
187
|
/**
|
|
173
|
-
* A linha "Criar" só quando há texto e nenhuma opção tem
|
|
174
|
-
*
|
|
188
|
+
* A linha "Criar" só quando há texto e nenhuma opção tem esse nome — com
|
|
189
|
+
* "Kronos" na lista, digitar "kronos" deve escolher, não criar; e com
|
|
190
|
+
* "Óleo", digitar "oleo" também.
|
|
175
191
|
*/
|
|
176
192
|
const podeCriar =
|
|
177
|
-
creatable &&
|
|
178
|
-
termo !== "" &&
|
|
179
|
-
!options.some((option) => option.name.toLowerCase() === termo.toLowerCase());
|
|
193
|
+
creatable && termo !== "" && !options.some((option) => sameText(option.name, termo));
|
|
180
194
|
|
|
181
195
|
/** O que se aponta pelo teclado: as opções filtradas e, no fim, a de criar. */
|
|
182
196
|
const visiveis = useMemo(
|
|
@@ -184,6 +198,26 @@ export function Combobox({
|
|
|
184
198
|
[filtradas, podeCriar, termo],
|
|
185
199
|
);
|
|
186
200
|
|
|
201
|
+
/*
|
|
202
|
+
* O valor do momento em que a criação termina, e não o do clique: no
|
|
203
|
+
* múltiplo, quem marcou outro item enquanto o POST corria não pode perder
|
|
204
|
+
* a marca quando o novo chega.
|
|
205
|
+
*/
|
|
206
|
+
const valorAtual = useRef(value);
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
valorAtual.current = value;
|
|
209
|
+
}, [value]);
|
|
210
|
+
|
|
211
|
+
const criar = (texto: string): void => {
|
|
212
|
+
Promise.resolve(onCreate?.(texto))
|
|
213
|
+
.then((novo) => {
|
|
214
|
+
if (novo) {
|
|
215
|
+
onChange(multiple ? [...valorAtual.current, novo] : [novo]);
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
.catch(() => undefined);
|
|
219
|
+
};
|
|
220
|
+
|
|
187
221
|
/** A posição do apontado; sem ele — ou fora da busca — é a do primeiro. */
|
|
188
222
|
const ativo = Math.max(
|
|
189
223
|
visiveis.findIndex((option) => option.id === apontado),
|
|
@@ -192,7 +226,7 @@ export function Combobox({
|
|
|
192
226
|
|
|
193
227
|
const alternar = (id: string): void => {
|
|
194
228
|
if (id === CREATE_ID) {
|
|
195
|
-
|
|
229
|
+
criar(termo);
|
|
196
230
|
setSearch("");
|
|
197
231
|
if (!multiple) {
|
|
198
232
|
setOpen(false);
|
|
@@ -32,6 +32,7 @@ export * from "./password-requirements";
|
|
|
32
32
|
export * from "./period-nav";
|
|
33
33
|
export * from "./popover";
|
|
34
34
|
export * from "./row-actions";
|
|
35
|
+
export * from "./searchable-select";
|
|
35
36
|
export * from "./segmented-control";
|
|
36
37
|
export * from "./select";
|
|
37
38
|
export * from "./separator";
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Calculator, Delete } from "lucide-react";
|
|
4
|
-
import type { JSX, KeyboardEvent } from "react";
|
|
4
|
+
import type { ChangeEvent, JSX, KeyboardEvent } from "react";
|
|
5
5
|
import { useRef, useState } from "react";
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
CalcError,
|
|
9
9
|
CalcErrorCode,
|
|
10
10
|
evaluateExpression,
|
|
11
|
+
glyphOf,
|
|
11
12
|
isCalcCharacter,
|
|
13
|
+
normalizeExpression,
|
|
12
14
|
toDisplayExpression,
|
|
13
15
|
} from "#core/_utils/calc";
|
|
14
16
|
import {
|
|
@@ -61,7 +63,7 @@ type KeyRole = "digit" | "operator" | "aux";
|
|
|
61
63
|
interface CalcKey {
|
|
62
64
|
/**
|
|
63
65
|
* O que a tecla acrescenta à conta — e também o seu desenho, passado pelo
|
|
64
|
-
* `
|
|
66
|
+
* `toGlyphs`: a tecla do `*` mostra `×` sem que o glifo precise
|
|
65
67
|
* ser escrito aqui de novo.
|
|
66
68
|
*/
|
|
67
69
|
input?: string;
|
|
@@ -126,8 +128,15 @@ export const MoneyInput = ({
|
|
|
126
128
|
}: MoneyInputProps): JSX.Element => {
|
|
127
129
|
const { t } = useI18n();
|
|
128
130
|
const [open, setOpen] = useState(false);
|
|
131
|
+
/*
|
|
132
|
+
A conta na forma canônica — símbolos, dinheiro só em dígitos (`4490`). O
|
|
133
|
+
visor mostra e edita a forma desenhada (`44,90`), e cada mudança volta
|
|
134
|
+
para cá por `normalizeExpression`: é o que deixa a pessoa digitar, apagar
|
|
135
|
+
e colar num campo formatado sem a máscara e o cursor brigarem.
|
|
136
|
+
*/
|
|
129
137
|
const [expression, setExpression] = useState("");
|
|
130
|
-
const
|
|
138
|
+
const visorRef = useRef<HTMLInputElement>(null);
|
|
139
|
+
const display: string = toDisplayExpression(expression);
|
|
131
140
|
|
|
132
141
|
/**
|
|
133
142
|
* Traduz o erro do parser. O código vem do `calc.util`, que não conhece
|
|
@@ -165,24 +174,115 @@ export const MoneyInput = ({
|
|
|
165
174
|
}
|
|
166
175
|
})();
|
|
167
176
|
|
|
168
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Abrir semeia a conta com o que já está no campo, para somar em cima.
|
|
179
|
+
*
|
|
180
|
+
* Em dígitos (`359`), e não no texto do campo (`3,59`): com a vírgula o
|
|
181
|
+
* número vira literal, e cada dígito digitado depois entrava como casa
|
|
182
|
+
* decimal a mais — `3,5922` arredondava de volta para 3,59, e o valor
|
|
183
|
+
* parecia travado. Em dígitos o visor mostra o mesmo 3,59, e digitar em
|
|
184
|
+
* cima empurra o valor como o campo faz.
|
|
185
|
+
*/
|
|
169
186
|
const handleOpenChange = (next: boolean): void => {
|
|
170
187
|
if (next) {
|
|
171
|
-
|
|
188
|
+
const cents: number = Math.round(parseCurrencyBR(value) * 100);
|
|
189
|
+
setExpression(cents > 0 ? String(cents) : "");
|
|
172
190
|
}
|
|
173
191
|
setOpen(next);
|
|
174
192
|
};
|
|
175
193
|
|
|
194
|
+
/**
|
|
195
|
+
* O visor como ficou depois de uma edição, e onde o cursor estava nele.
|
|
196
|
+
*
|
|
197
|
+
* O texto novo é normalizado e desenhado de novo — a máscara pode ter
|
|
198
|
+
* mudado de lugar (`449` é 4,49; `4490` é 44,90) —, e o cursor é posto
|
|
199
|
+
* com o mesmo número de caracteres "de verdade" (tudo que não é ponto,
|
|
200
|
+
* vírgula ou espaço) **à direita** dele. É o que faz digitar no meio de
|
|
201
|
+
* `44,90` cair onde a pessoa clicou, e não no fim.
|
|
202
|
+
*
|
|
203
|
+
* Contado do fim, e não do início, por causa do zero que a máscara põe na
|
|
204
|
+
* frente: `9` desenha `0,09`, e contando do início o cursor caía depois
|
|
205
|
+
* desse zero — o `8` seguinte entrava antes da vírgula, e `987` virava
|
|
206
|
+
* 80,79 em vez de 9,87.
|
|
207
|
+
*/
|
|
208
|
+
const commit = (nextDisplay: string, caret: number): void => {
|
|
209
|
+
const significantAfter: number = Array.from(nextDisplay.slice(caret)).filter(
|
|
210
|
+
(character) => !/[.,\s]/.test(character),
|
|
211
|
+
).length;
|
|
212
|
+
const raw: string = normalizeExpression(nextDisplay);
|
|
213
|
+
const redrawn: string = toDisplayExpression(raw);
|
|
214
|
+
|
|
215
|
+
let position: number = redrawn.length;
|
|
216
|
+
let seen = 0;
|
|
217
|
+
while (position > 0 && seen < significantAfter) {
|
|
218
|
+
if (!/[.,\s]/.test(redrawn[position - 1])) {
|
|
219
|
+
seen += 1;
|
|
220
|
+
}
|
|
221
|
+
position -= 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
setExpression(raw);
|
|
225
|
+
// Depois do render, quando o campo já mostra o texto redesenhado.
|
|
226
|
+
requestAnimationFrame(() => {
|
|
227
|
+
visorRef.current?.focus();
|
|
228
|
+
visorRef.current?.setSelectionRange(position, position);
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** Onde a seleção está no visor — no fim, se o campo ainda não tem foco. */
|
|
233
|
+
const selection = (): [number, number] => [
|
|
234
|
+
visorRef.current?.selectionStart ?? display.length,
|
|
235
|
+
visorRef.current?.selectionEnd ?? display.length,
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A tecla escreve onde o cursor está — e por cima do que estiver
|
|
240
|
+
* selecionado, como qualquer campo de texto.
|
|
241
|
+
*
|
|
242
|
+
* O visor foi um texto fixo em que as teclas só acrescentavam no fim: para
|
|
243
|
+
* trocar o `500` do meio da conta era apagar até lá e digitar tudo de novo,
|
|
244
|
+
* e selecionar tudo e digitar acrescentava em vez de substituir.
|
|
245
|
+
*/
|
|
246
|
+
const write = (text: string): void => {
|
|
247
|
+
const [start, end] = selection();
|
|
248
|
+
|
|
249
|
+
commit(display.slice(0, start) + text + display.slice(end), start + text.length);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* O backspace da tecla: apaga a seleção, ou o caractere antes do cursor —
|
|
254
|
+
* pulando o ponto e a vírgula, que são da máscara e voltam sozinhos.
|
|
255
|
+
*/
|
|
256
|
+
const erase = (): void => {
|
|
257
|
+
const [start, end] = selection();
|
|
258
|
+
let from: number = start;
|
|
259
|
+
|
|
260
|
+
if (start === end) {
|
|
261
|
+
while (from > 0 && /[.,]/.test(display[from - 1])) {
|
|
262
|
+
from -= 1;
|
|
263
|
+
}
|
|
264
|
+
from = Math.max(from - 1, 0);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
commit(display.slice(0, from) + display.slice(end), from);
|
|
268
|
+
};
|
|
269
|
+
|
|
176
270
|
const press = (key: CalcKey): void => {
|
|
177
271
|
if (key.action === "clear") {
|
|
178
272
|
setExpression("");
|
|
273
|
+
visorRef.current?.focus();
|
|
179
274
|
return;
|
|
180
275
|
}
|
|
181
276
|
if (key.action === "backspace") {
|
|
182
|
-
|
|
277
|
+
erase();
|
|
183
278
|
return;
|
|
184
279
|
}
|
|
185
|
-
|
|
280
|
+
write(key.input ?? "");
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** O que foi digitado, apagado ou colado direto no visor. */
|
|
284
|
+
const handleVisorChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
|
285
|
+
commit(event.target.value, event.target.selectionStart ?? event.target.value.length);
|
|
186
286
|
};
|
|
187
287
|
|
|
188
288
|
const apply = (): void => {
|
|
@@ -198,21 +298,16 @@ export const MoneyInput = ({
|
|
|
198
298
|
event.target instanceof HTMLElement && event.target.tagName === "BUTTON";
|
|
199
299
|
|
|
200
300
|
// Com o foco numa tecla, o Enter é o clique dela — mexer nisso quebraria
|
|
201
|
-
// quem navega por Tab.
|
|
301
|
+
// quem navega por Tab. No visor e no resto do painel, o Enter usa o valor.
|
|
202
302
|
if (event.key === "Enter" && !onButton) {
|
|
203
303
|
event.preventDefault();
|
|
204
304
|
apply();
|
|
205
305
|
return;
|
|
206
306
|
}
|
|
207
|
-
|
|
307
|
+
// Fora do visor (numa tecla, por Tab), digitar ainda escreve na conta.
|
|
308
|
+
if (event.target !== visorRef.current && event.key.length === 1 && isCalcCharacter(event.key)) {
|
|
208
309
|
event.preventDefault();
|
|
209
|
-
|
|
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));
|
|
310
|
+
write(event.key);
|
|
216
311
|
}
|
|
217
312
|
};
|
|
218
313
|
|
|
@@ -252,28 +347,39 @@ export const MoneyInput = ({
|
|
|
252
347
|
</PopoverAnchor>
|
|
253
348
|
|
|
254
349
|
<PopoverContent
|
|
255
|
-
ref={panelRef}
|
|
256
350
|
align="end"
|
|
257
351
|
aria-label={t("calculator.title")}
|
|
258
352
|
onKeyDown={handleKeyDown}
|
|
259
|
-
// O foco
|
|
260
|
-
// valor em vez de apertar o "C".
|
|
353
|
+
// O foco vai para o visor, com o cursor no fim: é onde se digita, e é
|
|
354
|
+
// onde o Enter usa o valor em vez de apertar o "C".
|
|
261
355
|
onOpenAutoFocus={(event) => {
|
|
262
356
|
event.preventDefault();
|
|
263
|
-
|
|
357
|
+
const visor = visorRef.current;
|
|
358
|
+
visor?.focus();
|
|
359
|
+
visor?.setSelectionRange(visor.value.length, visor.value.length);
|
|
264
360
|
}}
|
|
265
|
-
tabIndex={-1}
|
|
266
361
|
className="w-72"
|
|
267
362
|
>
|
|
363
|
+
{/*
|
|
364
|
+
O visor é a linha grande, e é editável: é nela que a pessoa clica
|
|
365
|
+
para trocar um número. O resultado fica embaixo, pequeno — é
|
|
366
|
+
consequência, não o que se mexe.
|
|
367
|
+
*/}
|
|
268
368
|
<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
|
-
<
|
|
270
|
-
{
|
|
271
|
-
|
|
369
|
+
<input
|
|
370
|
+
ref={visorRef}
|
|
371
|
+
value={display}
|
|
372
|
+
onChange={handleVisorChange}
|
|
373
|
+
aria-label={t("calculator.title")}
|
|
374
|
+
autoComplete="off"
|
|
375
|
+
spellCheck={false}
|
|
376
|
+
className="w-full bg-transparent text-right font-mono text-base font-medium tabular-nums outline-none"
|
|
377
|
+
/>
|
|
272
378
|
{outcome.message ? (
|
|
273
379
|
<span className="break-words text-xs text-destructive">{outcome.message}</span>
|
|
274
380
|
) : (
|
|
275
|
-
<span className="
|
|
276
|
-
{formatMoneyBR(outcome.value ?? 0)}
|
|
381
|
+
<span className="min-h-4 font-mono text-xs text-muted-foreground">
|
|
382
|
+
{expression ? `= ${formatMoneyBR(outcome.value ?? 0)}` : ""}
|
|
277
383
|
</span>
|
|
278
384
|
)}
|
|
279
385
|
</div>
|
|
@@ -285,6 +391,9 @@ export const MoneyInput = ({
|
|
|
285
391
|
type="button"
|
|
286
392
|
variant={key.role === "aux" ? "outline" : "secondary"}
|
|
287
393
|
aria-label={key.labelKey ? t(key.labelKey) : undefined}
|
|
394
|
+
// O clique não tira o foco do visor: é a seleção de lá que a
|
|
395
|
+
// tecla substitui, e o cursor de lá que ela avança.
|
|
396
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
288
397
|
onClick={() => press(key)}
|
|
289
398
|
className={cn(
|
|
290
399
|
"h-8 px-0 font-mono text-sm",
|
|
@@ -292,7 +401,7 @@ export const MoneyInput = ({
|
|
|
292
401
|
key.role === "aux" && "text-muted-foreground",
|
|
293
402
|
)}
|
|
294
403
|
>
|
|
295
|
-
{key.label ?? (key.input ?
|
|
404
|
+
{key.label ?? (key.input ? glyphOf(key.input) : <Delete />)}
|
|
296
405
|
</Button>
|
|
297
406
|
))}
|
|
298
407
|
</div>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { Combobox, type CreateResult } from "#core/components/ui/combobox";
|
|
6
|
+
import type { SelectOption } from "#core/components/ui/simple-select";
|
|
7
|
+
|
|
8
|
+
export interface SearchableSelectProps {
|
|
9
|
+
value: string;
|
|
10
|
+
onChange: (value: string) => void;
|
|
11
|
+
/** O mesmo formato do `SimpleSelect` — a troca entre os dois é o nome. */
|
|
12
|
+
options: SelectOption[];
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
searchPlaceholder?: string;
|
|
15
|
+
/** Mostra o X que devolve o campo ao vazio. Só onde o vazio é resposta. */
|
|
16
|
+
clearable?: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Deixa criar o que foi digitado quando nenhuma opção tem esse nome.
|
|
20
|
+
*
|
|
21
|
+
* É a flag por campo: o select de categoria da despesa liga, o filtro do
|
|
22
|
+
* extrato não. Quem cria é o `onCreate`, que devolve o id do registro
|
|
23
|
+
* novo — e o campo fica com ele selecionado, como se a pessoa tivesse
|
|
24
|
+
* escolhido da lista.
|
|
25
|
+
*/
|
|
26
|
+
creatable?: boolean;
|
|
27
|
+
onCreate?: (text: string) => CreateResult | Promise<CreateResult>;
|
|
28
|
+
/** Texto da linha de criação. Ausente, `Criar "{text}"`. */
|
|
29
|
+
createLabel?: (text: string) => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Escolha de **um** valor, com busca — o `Combobox` no formato de select.
|
|
34
|
+
*
|
|
35
|
+
* O `Combobox` fala em lista (`string[]`), porque também serve à escolha
|
|
36
|
+
* múltipla; aqui dentro fica a conversão que todo campo de um valor faria
|
|
37
|
+
* igual, e a tela continua passando `value`/`onChange` de texto como já
|
|
38
|
+
* fazia com o `SimpleSelect`.
|
|
39
|
+
*
|
|
40
|
+
* Por que não o `SimpleSelect` nesses campos: com uma dúzia de categorias
|
|
41
|
+
* cadastradas, achar "Combustível" numa lista sem busca é rolar até
|
|
42
|
+
* encontrar, e o teclado do Radix só salta para a opção — não filtra.
|
|
43
|
+
*/
|
|
44
|
+
export const SearchableSelect = ({
|
|
45
|
+
value,
|
|
46
|
+
onChange,
|
|
47
|
+
options,
|
|
48
|
+
placeholder,
|
|
49
|
+
searchPlaceholder,
|
|
50
|
+
clearable,
|
|
51
|
+
disabled,
|
|
52
|
+
creatable,
|
|
53
|
+
onCreate,
|
|
54
|
+
createLabel,
|
|
55
|
+
}: SearchableSelectProps): JSX.Element => (
|
|
56
|
+
<Combobox
|
|
57
|
+
options={options.map((option) => ({ id: option.value, name: option.label }))}
|
|
58
|
+
// Campo vazio é lista vazia: `[""]` seria uma opção de id em branco.
|
|
59
|
+
value={value ? [value] : []}
|
|
60
|
+
onChange={(ids) => onChange(ids[0] ?? "")}
|
|
61
|
+
placeholder={placeholder}
|
|
62
|
+
searchPlaceholder={searchPlaceholder}
|
|
63
|
+
clearable={clearable}
|
|
64
|
+
disabled={disabled}
|
|
65
|
+
creatable={creatable}
|
|
66
|
+
onCreate={onCreate}
|
|
67
|
+
createLabel={createLabel}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
@@ -3,22 +3,35 @@
|
|
|
3
3
|
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
|
|
4
4
|
import type { JSX } from "react";
|
|
5
5
|
|
|
6
|
-
import { formatDateTime } from "#core/_utils/format";
|
|
6
|
+
import { formatDateTime, formatRelativeTime } from "#core/_utils/format";
|
|
7
7
|
import { useI18n } from "#core/contexts";
|
|
8
8
|
import { NotificationType } from "#core/features/notifications/enums/notification-type.enum";
|
|
9
9
|
import type { UserNotification } from "#core/features/notifications/services/notifications.service";
|
|
10
10
|
import { cn } from "#core/lib/utils";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
12
|
+
/**
|
|
13
|
+
* Ícone e filete lateral saem da mesma cor: a severidade se lê no filete
|
|
14
|
+
* antes de ler o ícone, e as duas pistas discordando confundiriam.
|
|
15
|
+
*/
|
|
16
|
+
const SEVERITY: Record<NotificationType, { icon: JSX.Element; rail: string }> =
|
|
17
|
+
{
|
|
18
|
+
[NotificationType.INFO]: {
|
|
19
|
+
icon: <Info className="h-4 w-4 text-sky-500" />,
|
|
20
|
+
rail: "border-l-sky-500",
|
|
21
|
+
},
|
|
22
|
+
[NotificationType.SUCCESS]: {
|
|
23
|
+
icon: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
|
|
24
|
+
rail: "border-l-emerald-500",
|
|
25
|
+
},
|
|
26
|
+
[NotificationType.WARNING]: {
|
|
27
|
+
icon: <AlertTriangle className="h-4 w-4 text-amber-500" />,
|
|
28
|
+
rail: "border-l-amber-500",
|
|
29
|
+
},
|
|
30
|
+
[NotificationType.ERROR]: {
|
|
31
|
+
icon: <XCircle className="h-4 w-4 text-destructive" />,
|
|
32
|
+
rail: "border-l-destructive",
|
|
33
|
+
},
|
|
34
|
+
};
|
|
22
35
|
|
|
23
36
|
export interface NotificationItemProps {
|
|
24
37
|
notification: UserNotification;
|
|
@@ -27,12 +40,18 @@ export interface NotificationItemProps {
|
|
|
27
40
|
onDismiss: (id: string) => void;
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Uma linha compacta: título e hora na mesma linha, corpo em uma só. O sino é
|
|
45
|
+
* um popover, e cada linha a mais é uma notificação a menos visível sem rolar;
|
|
46
|
+
* o texto inteiro fica no `title` para o hover.
|
|
47
|
+
*/
|
|
30
48
|
export function NotificationItem({
|
|
31
49
|
notification,
|
|
32
50
|
onSelect,
|
|
33
51
|
onDismiss,
|
|
34
52
|
}: NotificationItemProps): JSX.Element {
|
|
35
53
|
const { locale, t } = useI18n();
|
|
54
|
+
const severity = SEVERITY[notification.type];
|
|
36
55
|
|
|
37
56
|
return (
|
|
38
57
|
/*
|
|
@@ -42,38 +61,44 @@ export function NotificationItem({
|
|
|
42
61
|
*/
|
|
43
62
|
<div
|
|
44
63
|
className={cn(
|
|
45
|
-
"group/notice relative border-
|
|
46
|
-
|
|
64
|
+
"group/notice relative border-l-[3px] border-l-transparent transition-colors",
|
|
65
|
+
// O filete só na não lida: é ele que diz "isto ainda pede atenção".
|
|
66
|
+
!notification.read && cn("bg-accent/35", severity.rail),
|
|
47
67
|
)}
|
|
48
68
|
>
|
|
49
69
|
<button
|
|
50
70
|
type="button"
|
|
51
71
|
onClick={() => onSelect(notification)}
|
|
52
|
-
className="flex w-full
|
|
72
|
+
className="flex w-full items-start gap-2.5 py-2 pl-3 pr-9 text-left hover:bg-accent"
|
|
53
73
|
>
|
|
54
|
-
<span className="mt-0.5 shrink-0">{
|
|
74
|
+
<span className="mt-0.5 shrink-0">{severity.icon}</span>
|
|
55
75
|
|
|
56
76
|
<span className="min-w-0 flex-1">
|
|
57
|
-
|
|
77
|
+
<span className="flex items-baseline gap-2">
|
|
78
|
+
<span
|
|
79
|
+
title={notification.title}
|
|
80
|
+
className={cn(
|
|
81
|
+
"min-w-0 flex-1 truncate text-sm",
|
|
82
|
+
!notification.read && "font-semibold",
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{notification.title}
|
|
86
|
+
</span>
|
|
87
|
+
<time
|
|
88
|
+
dateTime={notification.createdAt}
|
|
89
|
+
title={formatDateTime(notification.createdAt, locale)}
|
|
90
|
+
className="shrink-0 text-[11px] tabular-nums text-muted-foreground"
|
|
91
|
+
>
|
|
92
|
+
{formatRelativeTime(notification.createdAt, locale)}
|
|
93
|
+
</time>
|
|
94
|
+
</span>
|
|
95
|
+
|
|
58
96
|
<span
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
notification.read ? "text-foreground" : "font-semibold",
|
|
62
|
-
)}
|
|
97
|
+
title={notification.body}
|
|
98
|
+
className="block truncate text-xs text-muted-foreground"
|
|
63
99
|
>
|
|
64
|
-
{notification.
|
|
100
|
+
{notification.body}
|
|
65
101
|
</span>
|
|
66
|
-
{!notification.read && (
|
|
67
|
-
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
|
68
|
-
)}
|
|
69
|
-
</span>
|
|
70
|
-
|
|
71
|
-
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
72
|
-
{notification.body}
|
|
73
|
-
</span>
|
|
74
|
-
<span className="mt-1 block text-[11px] text-muted-foreground">
|
|
75
|
-
{formatDateTime(notification.createdAt, locale)}
|
|
76
|
-
</span>
|
|
77
102
|
</span>
|
|
78
103
|
</button>
|
|
79
104
|
|
|
@@ -84,7 +109,7 @@ export function NotificationItem({
|
|
|
84
109
|
onClick={() => onDismiss(notification.id)}
|
|
85
110
|
aria-label={t("notifications.dismiss")}
|
|
86
111
|
title={t("notifications.dismiss")}
|
|
87
|
-
className="absolute right-2 top-
|
|
112
|
+
className="absolute right-2 top-2 hidden rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground group-hover/notice:block group-focus-within/notice:block"
|
|
88
113
|
>
|
|
89
114
|
<X className="h-3.5 w-3.5" />
|
|
90
115
|
</button>
|
|
@@ -20,9 +20,44 @@ import { cn } from "#core/lib/utils";
|
|
|
20
20
|
|
|
21
21
|
type Tab = "all" | "unread" | "read";
|
|
22
22
|
|
|
23
|
+
const TABS: readonly Tab[] = ["all", "unread", "read"];
|
|
24
|
+
|
|
23
25
|
/** Acima disso o número não cabe no badge sem empurrar o layout. */
|
|
24
26
|
const BADGE_LIMIT = 99;
|
|
25
27
|
|
|
28
|
+
interface NotificationGroup {
|
|
29
|
+
source: string;
|
|
30
|
+
items: UserNotification[];
|
|
31
|
+
unread: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Agrupa pela origem (`source`), na ordem em que cada uma aparece na lista —
|
|
36
|
+
* como a lista vem da mais nova para a mais velha, o grupo com a notificação
|
|
37
|
+
* mais recente fica no topo. O sino não conhece os módulos do produto, então
|
|
38
|
+
* não existe ordem fixa para impor; quem não declarou origem cai no grupo
|
|
39
|
+
* padrão ("Sistema").
|
|
40
|
+
*/
|
|
41
|
+
export function groupBySource(
|
|
42
|
+
items: UserNotification[],
|
|
43
|
+
defaultSource: string,
|
|
44
|
+
): NotificationGroup[] {
|
|
45
|
+
const groups = new Map<string, NotificationGroup>();
|
|
46
|
+
|
|
47
|
+
for (const item of items) {
|
|
48
|
+
const source = item.source ?? defaultSource;
|
|
49
|
+
const group = groups.get(source) ?? { source, items: [], unread: 0 };
|
|
50
|
+
|
|
51
|
+
group.items.push(item);
|
|
52
|
+
if (!item.read) {
|
|
53
|
+
group.unread += 1;
|
|
54
|
+
}
|
|
55
|
+
groups.set(source, group);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [...groups.values()];
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
export function NotificationsCenter(): JSX.Element {
|
|
27
62
|
const { t } = useI18n();
|
|
28
63
|
const router = useRouter();
|
|
@@ -30,6 +65,9 @@ export function NotificationsCenter(): JSX.Element {
|
|
|
30
65
|
useNotifications();
|
|
31
66
|
const [tab, setTab] = useState<Tab>("all");
|
|
32
67
|
|
|
68
|
+
const read = items.length - unread;
|
|
69
|
+
const counts: Record<Tab, number> = { all: items.length, unread, read };
|
|
70
|
+
|
|
33
71
|
const visible =
|
|
34
72
|
tab === "unread"
|
|
35
73
|
? items.filter((i) => !i.read)
|
|
@@ -37,7 +75,7 @@ export function NotificationsCenter(): JSX.Element {
|
|
|
37
75
|
? items.filter((i) => i.read)
|
|
38
76
|
: items;
|
|
39
77
|
|
|
40
|
-
const
|
|
78
|
+
const groups = groupBySource(visible, t("notifications.sourceDefault"));
|
|
41
79
|
|
|
42
80
|
const onSelect = (notification: UserNotification): void => {
|
|
43
81
|
if (!notification.read) {
|
|
@@ -68,24 +106,8 @@ export function NotificationsCenter(): JSX.Element {
|
|
|
68
106
|
</DropdownMenuTrigger>
|
|
69
107
|
|
|
70
108
|
<DropdownMenuContent align="end" className="w-96 p-0">
|
|
71
|
-
<div className="flex items-center justify-between
|
|
72
|
-
<
|
|
73
|
-
{(["all", "unread", "read"] as const).map((value) => (
|
|
74
|
-
<button
|
|
75
|
-
key={value}
|
|
76
|
-
type="button"
|
|
77
|
-
onClick={() => setTab(value)}
|
|
78
|
-
className={cn(
|
|
79
|
-
"border-b-2 pb-1 text-sm transition-colors",
|
|
80
|
-
tab === value
|
|
81
|
-
? "border-primary font-medium text-foreground"
|
|
82
|
-
: "border-transparent text-muted-foreground hover:text-foreground",
|
|
83
|
-
)}
|
|
84
|
-
>
|
|
85
|
-
{t(`notifications.${value}`)}
|
|
86
|
-
</button>
|
|
87
|
-
))}
|
|
88
|
-
</div>
|
|
109
|
+
<div className="flex items-center justify-between px-4 pt-3">
|
|
110
|
+
<h2 className="text-sm font-semibold">{t("notifications.title")}</h2>
|
|
89
111
|
|
|
90
112
|
{/*
|
|
91
113
|
A ação do cabeçalho segue a aba. Em "Lidas" o que se quer é
|
|
@@ -96,7 +118,7 @@ export function NotificationsCenter(): JSX.Element {
|
|
|
96
118
|
<button
|
|
97
119
|
type="button"
|
|
98
120
|
onClick={() => void dismissRead()}
|
|
99
|
-
disabled={
|
|
121
|
+
disabled={read === 0}
|
|
100
122
|
className="text-xs font-medium text-primary hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline"
|
|
101
123
|
>
|
|
102
124
|
{t("notifications.clearRead")}
|
|
@@ -113,6 +135,39 @@ export function NotificationsCenter(): JSX.Element {
|
|
|
113
135
|
)}
|
|
114
136
|
</div>
|
|
115
137
|
|
|
138
|
+
<div
|
|
139
|
+
role="tablist"
|
|
140
|
+
className="flex gap-1 border-b border-border px-3 pt-2"
|
|
141
|
+
>
|
|
142
|
+
{TABS.map((value) => (
|
|
143
|
+
<button
|
|
144
|
+
key={value}
|
|
145
|
+
type="button"
|
|
146
|
+
role="tab"
|
|
147
|
+
aria-selected={tab === value}
|
|
148
|
+
onClick={() => setTab(value)}
|
|
149
|
+
className={cn(
|
|
150
|
+
"-mb-px inline-flex items-center gap-1.5 border-b-2 px-1.5 pb-2 pt-1 text-sm transition-colors",
|
|
151
|
+
tab === value
|
|
152
|
+
? "border-primary font-medium text-foreground"
|
|
153
|
+
: "border-transparent text-muted-foreground hover:text-foreground",
|
|
154
|
+
)}
|
|
155
|
+
>
|
|
156
|
+
{t(`notifications.${value}`)}
|
|
157
|
+
<span
|
|
158
|
+
className={cn(
|
|
159
|
+
"grid h-[18px] min-w-[18px] place-items-center rounded-full px-1.5 text-[10.5px] font-semibold",
|
|
160
|
+
tab === value
|
|
161
|
+
? "bg-primary/15 text-primary"
|
|
162
|
+
: "bg-muted text-muted-foreground",
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
{counts[value]}
|
|
166
|
+
</span>
|
|
167
|
+
</button>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
116
171
|
<div className="max-h-96 overflow-y-auto">
|
|
117
172
|
{loading && items.length === 0 && (
|
|
118
173
|
<div className="flex justify-center py-8">
|
|
@@ -126,13 +181,39 @@ export function NotificationsCenter(): JSX.Element {
|
|
|
126
181
|
</p>
|
|
127
182
|
)}
|
|
128
183
|
|
|
129
|
-
{
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
184
|
+
{groups.map((group) => (
|
|
185
|
+
<section key={group.source} aria-label={group.source}>
|
|
186
|
+
<header className="flex items-baseline justify-between px-3 pb-1 pt-2.5">
|
|
187
|
+
<span className="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
188
|
+
{group.source}
|
|
189
|
+
</span>
|
|
190
|
+
<span className="text-[11px] tabular-nums text-muted-foreground">
|
|
191
|
+
{group.unread > 0 && (
|
|
192
|
+
<>
|
|
193
|
+
<span className="font-semibold text-primary">
|
|
194
|
+
{group.unread}
|
|
195
|
+
</span>{" "}
|
|
196
|
+
{t(
|
|
197
|
+
group.unread === 1
|
|
198
|
+
? "notifications.unreadOne"
|
|
199
|
+
: "notifications.unreadMany",
|
|
200
|
+
)}
|
|
201
|
+
{" · "}
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
{group.items.length}
|
|
205
|
+
</span>
|
|
206
|
+
</header>
|
|
207
|
+
|
|
208
|
+
{group.items.map((notification) => (
|
|
209
|
+
<NotificationItem
|
|
210
|
+
key={notification.id}
|
|
211
|
+
notification={notification}
|
|
212
|
+
onSelect={onSelect}
|
|
213
|
+
onDismiss={(id) => void dismiss(id)}
|
|
214
|
+
/>
|
|
215
|
+
))}
|
|
216
|
+
</section>
|
|
136
217
|
))}
|
|
137
218
|
</div>
|
|
138
219
|
</DropdownMenuContent>
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -272,6 +272,9 @@ export const en: Messages = {
|
|
|
272
272
|
dismiss: "Remove from list",
|
|
273
273
|
markAllRead: "Mark all as read",
|
|
274
274
|
empty: "No notifications",
|
|
275
|
+
sourceDefault: "System",
|
|
276
|
+
unreadOne: "unread",
|
|
277
|
+
unreadMany: "unread",
|
|
275
278
|
},
|
|
276
279
|
calculator: {
|
|
277
280
|
open: "Open the calculator",
|
package/src/i18n/messages/pt.ts
CHANGED
|
@@ -275,6 +275,9 @@ export const pt = {
|
|
|
275
275
|
dismiss: "Tirar da lista",
|
|
276
276
|
markAllRead: "Marcar todas como lidas",
|
|
277
277
|
empty: "Nenhuma notificação",
|
|
278
|
+
sourceDefault: "Sistema",
|
|
279
|
+
unreadOne: "não lida",
|
|
280
|
+
unreadMany: "não lidas",
|
|
278
281
|
},
|
|
279
282
|
calculator: {
|
|
280
283
|
open: "Abrir a calculadora",
|
package/src/index.ts
CHANGED
|
@@ -64,10 +64,12 @@ export { anyOrTeam } from "#core/_utils/permission";
|
|
|
64
64
|
export {
|
|
65
65
|
formatCurrencyBR,
|
|
66
66
|
formatMoneyBR,
|
|
67
|
+
formatRelativeTime,
|
|
67
68
|
maskCurrencyBR,
|
|
68
69
|
maskPhoneBR,
|
|
69
70
|
parseCurrencyBR,
|
|
70
71
|
} from "#core/_utils/format";
|
|
72
|
+
export { matchesText, normalizeText, sameText } from "#core/_utils/text";
|
|
71
73
|
|
|
72
74
|
// ---------------------------------------------------------------------------
|
|
73
75
|
// Hooks de listagem, requisição e filtro
|