rl-core-front 0.19.2 → 0.19.6
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/format.ts +57 -0
- package/src/_utils/text.ts +25 -0
- package/src/components/ui/combobox.tsx +46 -12
- package/src/components/ui/data-table.tsx +140 -3
- package/src/components/ui/index.ts +1 -0
- 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/hooks/use-tab-state.ts +8 -46
- package/src/hooks/use-url-param.ts +80 -0
- package/src/i18n/messages/en.ts +3 -0
- package/src/i18n/messages/pt.ts +3 -0
- package/src/index.ts +3 -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/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);
|
|
@@ -280,6 +280,25 @@ interface DataTableProps<T extends RowData> {
|
|
|
280
280
|
* (a receber / recebidas) declara esta lista.
|
|
281
281
|
*/
|
|
282
282
|
groupOrder?: string[];
|
|
283
|
+
/**
|
|
284
|
+
* A linha que um link aponta — pelo id de `getRowId`.
|
|
285
|
+
*
|
|
286
|
+
* A tabela abre a seção em que ela está, rola até ela e a destaca por alguns
|
|
287
|
+
* segundos: é o que faz o aviso "luz venceu" cair na linha da luz, e não no
|
|
288
|
+
* topo de um mês de trinta lançamentos. Acontece quando o id troca e quando
|
|
289
|
+
* a linha chega (o dado costuma vir depois do link); fora disso a linha é
|
|
290
|
+
* uma linha como as outras — o destaque é um sinal, não um estado.
|
|
291
|
+
*
|
|
292
|
+
* Exige `getRowId`. Id que não está em `data` não faz nada.
|
|
293
|
+
*/
|
|
294
|
+
highlightRowId?: string | null;
|
|
295
|
+
/**
|
|
296
|
+
* A seção que um link aponta — pela chave de `groupBy`.
|
|
297
|
+
*
|
|
298
|
+
* O mesmo gesto, na faixa: para o aviso que fala da fatura inteira, e não de
|
|
299
|
+
* uma parcela dela.
|
|
300
|
+
*/
|
|
301
|
+
highlightGroupKey?: string | null;
|
|
283
302
|
}
|
|
284
303
|
|
|
285
304
|
/** Uma seção da listagem, como o cabeçalho de grupo a recebe. */
|
|
@@ -458,6 +477,20 @@ function shrinkClass(columnDef: { meta?: unknown }): string | undefined {
|
|
|
458
477
|
*/
|
|
459
478
|
const CELL_MAX_WIDTH = "24rem";
|
|
460
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Quanto tempo a linha apontada fica destacada.
|
|
482
|
+
*
|
|
483
|
+
* O bastante para o olho chegar depois da rolagem e curto o bastante para não
|
|
484
|
+
* parecer seleção: passado isso, a cor esmaece e a linha volta ao normal.
|
|
485
|
+
*/
|
|
486
|
+
const HIGHLIGHT_MS = 3000;
|
|
487
|
+
|
|
488
|
+
/** O que está destacado agora — a linha, a faixa, ou nada. */
|
|
489
|
+
interface Spotlight {
|
|
490
|
+
rowId: string | null;
|
|
491
|
+
groupKey: string | null;
|
|
492
|
+
}
|
|
493
|
+
|
|
461
494
|
/**
|
|
462
495
|
* Como a célula se comporta quando o conteúdo não cabe: corta com reticências.
|
|
463
496
|
*
|
|
@@ -571,6 +604,8 @@ export function DataTable<T extends RowData>({
|
|
|
571
604
|
groupOrder,
|
|
572
605
|
collapsibleGroups = false,
|
|
573
606
|
defaultCollapsedGroups,
|
|
607
|
+
highlightRowId = null,
|
|
608
|
+
highlightGroupKey = null,
|
|
574
609
|
}: DataTableProps<T>): JSX.Element {
|
|
575
610
|
const { t } = useI18n();
|
|
576
611
|
const [colsOpen, setColsOpen] = useState(false);
|
|
@@ -578,6 +613,93 @@ export function DataTable<T extends RowData>({
|
|
|
578
613
|
const [collapsedGroups, setCollapsedGroups] = useState<string[]>(
|
|
579
614
|
() => defaultCollapsedGroups ?? [],
|
|
580
615
|
);
|
|
616
|
+
/*
|
|
617
|
+
O que está apontado e se ainda está aceso.
|
|
618
|
+
|
|
619
|
+
Dois estados porque a transição de cor fica ligada na linha apontada mesmo
|
|
620
|
+
depois de a cor sair: se ela morasse só enquanto acesa, sumiria junto com
|
|
621
|
+
a cor e o esmaecer viraria um corte. E só nessa linha, e não em todas —
|
|
622
|
+
com a transição na tabela inteira, o hover de cada linha demoraria para
|
|
623
|
+
acender.
|
|
624
|
+
*/
|
|
625
|
+
const [spotlight, setSpotlight] = useState<Spotlight | null>(null);
|
|
626
|
+
const [lit, setLit] = useState(false);
|
|
627
|
+
/** O pedido já atendido — para o mesmo link não acender a linha a cada render. */
|
|
628
|
+
const [seenHighlight, setSeenHighlight] = useState<string | null>(null);
|
|
629
|
+
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
|
630
|
+
|
|
631
|
+
/*
|
|
632
|
+
A linha apontada, se ela está em `data`; e a seção dela, para abrir.
|
|
633
|
+
|
|
634
|
+
Procurada em `data` e não no modelo da tabela: fechada, a seção tira as
|
|
635
|
+
linhas do modelo, e a linha que se quer abrir é justamente a que está
|
|
636
|
+
escondida.
|
|
637
|
+
*/
|
|
638
|
+
const highlightedRow =
|
|
639
|
+
highlightRowId !== null && getRowId
|
|
640
|
+
? data.find((row) => getRowId(row) === highlightRowId)
|
|
641
|
+
: undefined;
|
|
642
|
+
const highlightedGroup =
|
|
643
|
+
highlightGroupKey ??
|
|
644
|
+
(highlightedRow !== undefined && groupBy ? groupBy(highlightedRow) : null);
|
|
645
|
+
const highlightTarget =
|
|
646
|
+
highlightedRow !== undefined ||
|
|
647
|
+
(highlightGroupKey !== null && Boolean(groupBy) &&
|
|
648
|
+
data.some((row) => groupBy?.(row) === highlightGroupKey));
|
|
649
|
+
const highlightKey = highlightTarget
|
|
650
|
+
? `${highlightRowId ?? ""}|${highlightGroupKey ?? ""}`
|
|
651
|
+
: null;
|
|
652
|
+
|
|
653
|
+
/*
|
|
654
|
+
Reage ao link no próprio render, e não num efeito: é o padrão do React
|
|
655
|
+
para "estado que depende da prop anterior", e poupa o quadro em que a
|
|
656
|
+
seção ainda estaria fechada. Quando o alvo some da lista (a tela trocou de
|
|
657
|
+
mês), o pedido é esquecido — e a linha acende de novo se voltar.
|
|
658
|
+
*/
|
|
659
|
+
if (highlightKey !== seenHighlight) {
|
|
660
|
+
setSeenHighlight(highlightKey);
|
|
661
|
+
|
|
662
|
+
if (highlightKey !== null) {
|
|
663
|
+
setSpotlight({ rowId: highlightRowId, groupKey: highlightGroupKey });
|
|
664
|
+
setLit(true);
|
|
665
|
+
|
|
666
|
+
if (highlightedGroup !== null) {
|
|
667
|
+
setCollapsedGroups((atual) =>
|
|
668
|
+
atual.filter((fechada) => fechada !== highlightedGroup),
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
useEffect(() => {
|
|
675
|
+
if (!lit) {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const timer = window.setTimeout(() => setLit(false), HIGHLIGHT_MS);
|
|
680
|
+
|
|
681
|
+
return () => window.clearTimeout(timer);
|
|
682
|
+
// `spotlight` entra para o relógio recomeçar quando o alvo troca com a
|
|
683
|
+
// linha anterior ainda acesa.
|
|
684
|
+
}, [lit, spotlight]);
|
|
685
|
+
|
|
686
|
+
// Rola depois de a seção abrir: a linha só existe no DOM depois do render
|
|
687
|
+
// que a abriu, e um efeito roda justamente depois dele.
|
|
688
|
+
useEffect(() => {
|
|
689
|
+
if (spotlight === null) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const selector =
|
|
694
|
+
spotlight.rowId !== null
|
|
695
|
+
? `[data-row-id="${CSS.escape(spotlight.rowId)}"]`
|
|
696
|
+
: spotlight.groupKey !== null
|
|
697
|
+
? `[data-group-key="${CSS.escape(spotlight.groupKey)}"]`
|
|
698
|
+
: null;
|
|
699
|
+
const alvo = selector ? bodyRef.current?.querySelector(selector) : null;
|
|
700
|
+
|
|
701
|
+
alvo?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
702
|
+
}, [spotlight]);
|
|
581
703
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
582
704
|
/** A linha na mão e a linha embaixo do dedo, enquanto o arrasto dura. */
|
|
583
705
|
const [draggedRow, setDraggedRow] = useState<string | null>(null);
|
|
@@ -1102,7 +1224,7 @@ export function DataTable<T extends RowData>({
|
|
|
1102
1224
|
</TableRow>
|
|
1103
1225
|
))}
|
|
1104
1226
|
</TableHeader>
|
|
1105
|
-
<TableBody>
|
|
1227
|
+
<TableBody ref={bodyRef}>
|
|
1106
1228
|
{loading && (
|
|
1107
1229
|
<TableRow>
|
|
1108
1230
|
<TableCell colSpan={colSpan} className="h-24 text-center">
|
|
@@ -1124,13 +1246,21 @@ export function DataTable<T extends RowData>({
|
|
|
1124
1246
|
sections.map((section) => (
|
|
1125
1247
|
<Fragment key={section.key}>
|
|
1126
1248
|
{section.header && renderGroupHeader && (
|
|
1127
|
-
<TableRow
|
|
1249
|
+
<TableRow
|
|
1250
|
+
data-group-key={section.key}
|
|
1251
|
+
className="hover:bg-transparent"
|
|
1252
|
+
>
|
|
1128
1253
|
{/* Uma célula atravessando a tabela: é o que faz a
|
|
1129
1254
|
faixa parecer cabeçalho, e não registro sem
|
|
1130
1255
|
dados. */}
|
|
1131
1256
|
<TableCell
|
|
1132
1257
|
colSpan={colSpan}
|
|
1133
|
-
className=
|
|
1258
|
+
className={cn(
|
|
1259
|
+
"bg-muted/40 py-2",
|
|
1260
|
+
spotlight?.groupKey === section.key &&
|
|
1261
|
+
"transition-colors duration-700",
|
|
1262
|
+
lit && spotlight?.groupKey === section.key && "bg-primary/15",
|
|
1263
|
+
)}
|
|
1134
1264
|
>
|
|
1135
1265
|
{collapsibleGroups ? (
|
|
1136
1266
|
<button
|
|
@@ -1174,6 +1304,13 @@ export function DataTable<T extends RowData>({
|
|
|
1174
1304
|
dragging &&
|
|
1175
1305
|
"relative z-20 bg-card shadow-xl ring-1 ring-primary/50",
|
|
1176
1306
|
draggedRow !== null && "select-none",
|
|
1307
|
+
// A linha apontada acende e esmaece: a cor some
|
|
1308
|
+
// sozinha quando o destaque acaba, e a transição
|
|
1309
|
+
// é o que faz isso parecer "olhe aqui", e não
|
|
1310
|
+
// uma seleção que ficou.
|
|
1311
|
+
spotlight?.rowId === row.id &&
|
|
1312
|
+
"transition-colors duration-700",
|
|
1313
|
+
lit && spotlight?.rowId === row.id && "bg-primary/15",
|
|
1177
1314
|
/*
|
|
1178
1315
|
A transição existe só enquanto o arrasto dura, e só nas
|
|
1179
1316
|
linhas paradas: na que segue o dedo ela viraria atraso,
|
|
@@ -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";
|
|
@@ -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>
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
useCallback,
|
|
5
|
-
useEffect,
|
|
6
|
-
useLayoutEffect,
|
|
7
|
-
useRef,
|
|
8
|
-
useSyncExternalStore,
|
|
9
|
-
} from "react";
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
10
4
|
|
|
11
5
|
import { safeStorage } from "#core/_utils/storage";
|
|
12
6
|
import { useIsHydrated } from "#core/hooks/use-is-hydrated";
|
|
13
7
|
import { useStoredValue } from "#core/hooks/use-stored-value";
|
|
8
|
+
import { readUrlParam, useUrlParam } from "#core/hooks/use-url-param";
|
|
14
9
|
|
|
15
10
|
const STORAGE_PREFIX = "tab:";
|
|
16
11
|
const DEFAULT_PARAM = "tab";
|
|
@@ -33,35 +28,6 @@ export interface UseTabStateResult<T extends string> {
|
|
|
33
28
|
setTab: (next: string) => void;
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
const readFromUrl = (param: string): string | null =>
|
|
37
|
-
new URLSearchParams(window.location.search).get(param);
|
|
38
|
-
|
|
39
|
-
/** Quem lê a querystring, para saber que ela mudou por aqui. */
|
|
40
|
-
const urlListeners = new Set<() => void>();
|
|
41
|
-
|
|
42
|
-
const subscribeUrl = (onChange: () => void): (() => void) => {
|
|
43
|
-
urlListeners.add(onChange);
|
|
44
|
-
// O "voltar" do navegador também troca a aba, e esse não passa por aqui.
|
|
45
|
-
window.addEventListener("popstate", onChange);
|
|
46
|
-
return () => {
|
|
47
|
-
urlListeners.delete(onChange);
|
|
48
|
-
window.removeEventListener("popstate", onChange);
|
|
49
|
-
};
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/** Troca só o próprio parâmetro, preservando o resto — o `?filter=` da listagem mora ao lado. */
|
|
53
|
-
const writeToUrl = (param: string, value: string): void => {
|
|
54
|
-
const params = new URLSearchParams(window.location.search);
|
|
55
|
-
params.set(param, value);
|
|
56
|
-
window.history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
|
|
57
|
-
|
|
58
|
-
// `replaceState` não dispara evento nenhum: sem este aviso, a tela ficaria
|
|
59
|
-
// na aba antiga até o próximo render por outro motivo.
|
|
60
|
-
for (const listener of urlListeners) {
|
|
61
|
-
listener();
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
|
|
65
31
|
/**
|
|
66
32
|
* Em qual aba a pessoa deixou a tela, por `storageKey`.
|
|
67
33
|
*
|
|
@@ -93,11 +59,7 @@ export function useTabState<T extends string>(
|
|
|
93
59
|
// dentro de um efeito: no primeiro render do Next não há `window`, e a
|
|
94
60
|
// leitura do cliente entra assim que ele hidrata, sem um quadro na aba
|
|
95
61
|
// errada.
|
|
96
|
-
const urlTab =
|
|
97
|
-
subscribeUrl,
|
|
98
|
-
() => readFromUrl(param),
|
|
99
|
-
() => null,
|
|
100
|
-
);
|
|
62
|
+
const [urlTab, writeUrlTab] = useUrlParam(param);
|
|
101
63
|
const storedTab = useStoredValue(`${STORAGE_PREFIX}${storageKey}`);
|
|
102
64
|
|
|
103
65
|
const isTab = (value: string | null): value is T =>
|
|
@@ -127,10 +89,10 @@ export function useTabState<T extends string>(
|
|
|
127
89
|
if (!hydrated) {
|
|
128
90
|
return;
|
|
129
91
|
}
|
|
130
|
-
if (tab !== undefined &&
|
|
131
|
-
|
|
92
|
+
if (tab !== undefined && readUrlParam(param) !== tab) {
|
|
93
|
+
writeUrlTab(tab);
|
|
132
94
|
}
|
|
133
|
-
}, [hydrated, tab, param]);
|
|
95
|
+
}, [hydrated, tab, param, writeUrlTab]);
|
|
134
96
|
|
|
135
97
|
const setTab = useCallback(
|
|
136
98
|
(next: string): void => {
|
|
@@ -139,10 +101,10 @@ export function useTabState<T extends string>(
|
|
|
139
101
|
}
|
|
140
102
|
|
|
141
103
|
// Escrever nos dois já avisa quem lê: a aba vem da URL e do storage.
|
|
142
|
-
|
|
104
|
+
writeUrlTab(next);
|
|
143
105
|
safeStorage.set(`${STORAGE_PREFIX}${storageKey}`, next);
|
|
144
106
|
},
|
|
145
|
-
[storageKey,
|
|
107
|
+
[storageKey, writeUrlTab],
|
|
146
108
|
);
|
|
147
109
|
|
|
148
110
|
return { tab, setTab };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
/** Quem lê a querystring, para saber que ela mudou por aqui. */
|
|
6
|
+
const listeners = new Set<() => void>();
|
|
7
|
+
|
|
8
|
+
const subscribe = (onChange: () => void): (() => void) => {
|
|
9
|
+
listeners.add(onChange);
|
|
10
|
+
// O "voltar" do navegador também troca o valor, e esse não passa por aqui.
|
|
11
|
+
window.addEventListener("popstate", onChange);
|
|
12
|
+
return () => {
|
|
13
|
+
listeners.delete(onChange);
|
|
14
|
+
window.removeEventListener("popstate", onChange);
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** O valor de um parâmetro da URL, ou `null` quando ele não está lá. */
|
|
19
|
+
export const readUrlParam = (name: string): string | null =>
|
|
20
|
+
new URLSearchParams(window.location.search).get(name);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Troca só o próprio parâmetro, preservando o resto — o `?filter=` da listagem
|
|
24
|
+
* mora ao lado. `null` o remove.
|
|
25
|
+
*
|
|
26
|
+
* `replaceState`, e não `router.replace`: trocar um parâmetro não é navegação,
|
|
27
|
+
* e empilhar histórico faria o "voltar" percorrer valor por valor antes de sair
|
|
28
|
+
* da tela.
|
|
29
|
+
*/
|
|
30
|
+
export const writeUrlParam = (name: string, value: string | null): void => {
|
|
31
|
+
const params = new URLSearchParams(window.location.search);
|
|
32
|
+
|
|
33
|
+
if (value === null) {
|
|
34
|
+
params.delete(name);
|
|
35
|
+
} else {
|
|
36
|
+
params.set(name, value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const query = params.toString();
|
|
40
|
+
window.history.replaceState(
|
|
41
|
+
null,
|
|
42
|
+
"",
|
|
43
|
+
query ? `${window.location.pathname}?${query}` : window.location.pathname,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// `replaceState` não dispara evento nenhum: sem este aviso, quem lê o
|
|
47
|
+
// parâmetro ficaria com o valor antigo até o próximo render por outro motivo.
|
|
48
|
+
for (const listener of listeners) {
|
|
49
|
+
listener();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Um parâmetro da querystring como estado: `[valor, trocar]`.
|
|
55
|
+
*
|
|
56
|
+
* É o que faz o F5 voltar onde estava e transforma o estado em link
|
|
57
|
+
* compartilhável — o mês em exibição, a linha que um aviso aponta. Lido no
|
|
58
|
+
* próprio render, e não copiado para um estado num efeito: no primeiro render
|
|
59
|
+
* do Next não há `window`, e a leitura do cliente entra assim que ele hidrata,
|
|
60
|
+
* sem um quadro no valor errado.
|
|
61
|
+
*
|
|
62
|
+
* O servidor sempre enxerga `null`: quem tem padrão aplica na leitura
|
|
63
|
+
* (`value ?? padrao`), e o padrão vale nos dois lados.
|
|
64
|
+
*/
|
|
65
|
+
export function useUrlParam(
|
|
66
|
+
name: string,
|
|
67
|
+
): [value: string | null, set: (value: string | null) => void] {
|
|
68
|
+
const value = useSyncExternalStore(
|
|
69
|
+
subscribe,
|
|
70
|
+
() => readUrlParam(name),
|
|
71
|
+
() => null,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const set = useCallback(
|
|
75
|
+
(next: string | null): void => writeUrlParam(name, next),
|
|
76
|
+
[name],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return [value, set];
|
|
80
|
+
}
|
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
|
|
@@ -129,6 +131,7 @@ export type { UseTabStateOptions, UseTabStateResult } from "#core/hooks/use-tab-
|
|
|
129
131
|
export { useTabState } from "#core/hooks/use-tab-state";
|
|
130
132
|
export type { TableView, UseTableViewResult } from "#core/hooks/use-table-view";
|
|
131
133
|
export { useTableView } from "#core/hooks/use-table-view";
|
|
134
|
+
export { useUrlParam } from "#core/hooks/use-url-param";
|
|
132
135
|
// RequestOperation é enum (valor, não só tipo): sem ele o projeto não consegue
|
|
133
136
|
// pedir o toast padrão de criado/alterado/removido que o core já traduz.
|
|
134
137
|
export { RequestOperation, useRequest } from "#core/hooks/use-request";
|