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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.19.1",
3
+ "version": "0.19.5",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -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: {
@@ -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 como se lê na tela: `120*2+10%` vira `120×2+10%`.
101
+ * A conta em pedaços, com cada número já sabendo se é dinheiro.
88
102
  *
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.
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
- Array.from(source)
94
- .map((character) => SYMBOL_TO_GLYPH.get(character) ?? character)
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
- * 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.
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
- tokens.push({ type: "number", value: toNumber(source.slice(index, end)) });
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
  }
@@ -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
- /** Chamado com o texto digitado, já sem espaços nas pontas. */
50
- onCreate?: (text: string) => void;
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 exatamente esse
174
- * nome — com "Kronos" na lista, digitar "kronos" deve escolher, não criar.
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
- onCreate?.(termo);
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
- * `toDisplayExpression`: a tecla do `*` mostra `×` sem que o glifo precise
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 panelRef = useRef<HTMLDivElement>(null);
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
- /** Abrir semeia a conta com o que já está no campo, para somar em cima. */
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
- setExpression(parseCurrencyBR(value) > 0 ? value : "");
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
- setExpression((current) => current.slice(0, -1));
277
+ erase();
183
278
  return;
184
279
  }
185
- setExpression((current) => current + (key.input ?? ""));
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
- if (event.key === "Backspace") {
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
- 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));
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 fica no painel, e não na primeira tecla: assim o Enter usa o
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
- panelRef.current?.focus();
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
- <span className="min-h-5 break-all font-mono text-xs text-muted-foreground">
270
- {toDisplayExpression(expression)}
271
- </span>
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="break-all font-mono text-base font-medium tabular-nums">
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 ? toDisplayExpression(key.input) : <Delete />)}
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
- const ICONS: Record<NotificationType, JSX.Element> = {
13
- [NotificationType.INFO]: <Info className="h-4 w-4 text-sky-500" />,
14
- [NotificationType.SUCCESS]: (
15
- <CheckCircle2 className="h-4 w-4 text-emerald-500" />
16
- ),
17
- [NotificationType.WARNING]: (
18
- <AlertTriangle className="h-4 w-4 text-amber-500" />
19
- ),
20
- [NotificationType.ERROR]: <XCircle className="h-4 w-4 text-destructive" />,
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-b border-border transition-colors hover:bg-accent",
46
- !notification.read && "bg-accent/40",
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 gap-3 px-4 py-3 pr-10 text-left"
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">{ICONS[notification.type]}</span>
74
+ <span className="mt-0.5 shrink-0">{severity.icon}</span>
55
75
 
56
76
  <span className="min-w-0 flex-1">
57
- <span className="flex items-center gap-2">
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
- className={cn(
60
- "truncate text-sm",
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.title}
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-3 hidden rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground group-hover/notice:block"
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 lidas = items.filter((i) => i.read).length;
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 border-b border-border px-4 py-2">
72
- <div className="flex gap-3">
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={lidas === 0}
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
- {visible.map((notification) => (
130
- <NotificationItem
131
- key={notification.id}
132
- notification={notification}
133
- onSelect={onSelect}
134
- onDismiss={(id) => void dismiss(id)}
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>
@@ -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",
@@ -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