rl-core-front 0.19.2 → 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/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/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/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";
|
|
@@ -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
|