@fayz-ai/plugin-notifications 0.2.0 → 0.2.1
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/dist/components/TemplateEditorDialog.d.ts +11 -0
- package/dist/components/TemplateEditorDialog.d.ts.map +1 -0
- package/dist/context.d.ts +8 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/data/mock.d.ts +3 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +3 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/tables.d.ts +7 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/types.d.ts +9 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/index.d.ts +21 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +980 -11
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/types.d.ts +82 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/HistoryView.d.ts +3 -0
- package/dist/views/HistoryView.d.ts.map +1 -0
- package/dist/views/NotificationsPage.d.ts +6 -0
- package/dist/views/NotificationsPage.d.ts.map +1 -0
- package/dist/views/TemplatesView.d.ts +3 -0
- package/dist/views/TemplatesView.d.ts.map +1 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import React2 from 'react';
|
|
2
|
+
import { registerTranslations, getActiveTenantId, useTranslation, getSupabaseClientOptional } from '@fayz-ai/core';
|
|
3
|
+
import { createSafeDataProvider, useModuleNavigation, createViewRouter, usePermissionOptional, PageActions } from '@fayz-ai/admin';
|
|
4
|
+
import { ModulePage, SubpageHeader, Button, Switch, ConfirmDialog, toast, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, Input, ModalFooter } from '@fayz-ai/ui';
|
|
5
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
6
|
+
import { Plus, MessageSquare, Pencil, Trash2, Clock, CheckCircle2, AlertTriangle, Ban, Loader2 } from 'lucide-react';
|
|
2
7
|
|
|
3
8
|
// src/index.ts
|
|
4
9
|
|
|
@@ -9,7 +14,61 @@ var en = {
|
|
|
9
14
|
"notifications.queue": "Queue",
|
|
10
15
|
"notifications.preferences": "Preferences",
|
|
11
16
|
"notifications.blocked": "Blocked",
|
|
12
|
-
"notifications.abandoned": "Abandoned"
|
|
17
|
+
"notifications.abandoned": "Abandoned",
|
|
18
|
+
"notifications.page.title": "Communication",
|
|
19
|
+
"notifications.page.subtitle": "What the house sends on its own \u2014 and what never arrived.",
|
|
20
|
+
"notifications.templates.title": "Templates",
|
|
21
|
+
"notifications.templates.subtitle": "The texts that go out automatically. The same template can exist on several channels.",
|
|
22
|
+
"notifications.templates.new": "New template",
|
|
23
|
+
"notifications.templates.newTitle": "New template",
|
|
24
|
+
"notifications.templates.editTitle": "Edit template",
|
|
25
|
+
"notifications.templates.editorDescription": "The key is the contract: it is what a send asks for. The wording changes; the key does not.",
|
|
26
|
+
"notifications.templates.key": "Key",
|
|
27
|
+
"notifications.templates.channel": "Channel",
|
|
28
|
+
"notifications.templates.purpose": "Type",
|
|
29
|
+
"notifications.templates.subject": "Subject",
|
|
30
|
+
"notifications.templates.body": "Body",
|
|
31
|
+
"notifications.templates.bodyPlaceholder": "Hi {{name}}! \u2026",
|
|
32
|
+
"notifications.templates.active": "Active",
|
|
33
|
+
"notifications.templates.varsHint": "Use {{name}} to slot in data from the send.",
|
|
34
|
+
"notifications.templates.varsFound": "Fields used: {{vars}}",
|
|
35
|
+
"notifications.templates.keyRequired": "The template needs a key.",
|
|
36
|
+
"notifications.templates.bodyRequired": "The template needs a body.",
|
|
37
|
+
"notifications.templates.saveFailed": "Could not save the template.",
|
|
38
|
+
"notifications.templates.emptyTitle": "No templates yet.",
|
|
39
|
+
"notifications.templates.emptyHint": "Without a template there is nothing to send: the engine has a queue, retries and error records, but nothing to write in the message.",
|
|
40
|
+
"notifications.templates.deleteTitle": "Delete template",
|
|
41
|
+
"notifications.templates.deleteDescription": '"{{key}}" stops existing. Sends asking for this key will find nothing.',
|
|
42
|
+
"notifications.history.title": "Send history",
|
|
43
|
+
"notifications.history.subtitle": "What went out, what failed, and why.",
|
|
44
|
+
"notifications.history.total": "Messages",
|
|
45
|
+
"notifications.history.sent": "Sent",
|
|
46
|
+
"notifications.history.failed": "Failed",
|
|
47
|
+
"notifications.history.blocked": "Blocked",
|
|
48
|
+
"notifications.history.topReason": "{{count}} messages did not arrive for the SAME reason: {{reason}}.",
|
|
49
|
+
"notifications.history.allChannels": "All channels",
|
|
50
|
+
"notifications.history.allStatuses": "All statuses",
|
|
51
|
+
"notifications.history.when": "Date/Time",
|
|
52
|
+
"notifications.history.template": "Template",
|
|
53
|
+
"notifications.history.who": "Who",
|
|
54
|
+
"notifications.history.channel": "Channel",
|
|
55
|
+
"notifications.history.status": "Status",
|
|
56
|
+
"notifications.history.reason": "Reason",
|
|
57
|
+
"notifications.history.attempts": "{{n}} attempts",
|
|
58
|
+
"notifications.history.empty": "No sends in this period.",
|
|
59
|
+
"notifications.channel.whatsapp": "WhatsApp",
|
|
60
|
+
"notifications.channel.email": "Email",
|
|
61
|
+
"notifications.channel.sms": "SMS",
|
|
62
|
+
"notifications.channel.push": "Push",
|
|
63
|
+
"notifications.channel.in_app": "In app",
|
|
64
|
+
"notifications.status.sent": "Sent",
|
|
65
|
+
"notifications.status.failed": "Failed",
|
|
66
|
+
"notifications.status.blocked": "Blocked",
|
|
67
|
+
"notifications.status.pending": "Queued",
|
|
68
|
+
"notifications.status.abandoned": "Gave up",
|
|
69
|
+
"notifications.status.cancelled": "Cancelled",
|
|
70
|
+
"notifications.purpose.transactional": "Notice",
|
|
71
|
+
"notifications.purpose.marketing": "Marketing"
|
|
13
72
|
};
|
|
14
73
|
|
|
15
74
|
// src/locales/pt-BR.ts
|
|
@@ -19,7 +78,61 @@ var ptBR = {
|
|
|
19
78
|
"notifications.queue": "Fila",
|
|
20
79
|
"notifications.preferences": "Prefer\xEAncias",
|
|
21
80
|
"notifications.blocked": "Bloqueadas",
|
|
22
|
-
"notifications.abandoned": "Abandonadas"
|
|
81
|
+
"notifications.abandoned": "Abandonadas",
|
|
82
|
+
"notifications.page.title": "Comunica\xE7\xE3o",
|
|
83
|
+
"notifications.page.subtitle": "O que a casa envia sozinha \u2014 e o que n\xE3o chegou.",
|
|
84
|
+
"notifications.templates.title": "Modelos",
|
|
85
|
+
"notifications.templates.subtitle": "Os textos que saem automaticamente. O mesmo modelo pode existir em v\xE1rios canais.",
|
|
86
|
+
"notifications.templates.new": "Novo modelo",
|
|
87
|
+
"notifications.templates.newTitle": "Novo modelo",
|
|
88
|
+
"notifications.templates.editTitle": "Editar modelo",
|
|
89
|
+
"notifications.templates.editorDescription": "A chave \xE9 o contrato: \xE9 por ela que o disparo pede este modelo. O nome do texto muda; a chave n\xE3o.",
|
|
90
|
+
"notifications.templates.key": "Chave",
|
|
91
|
+
"notifications.templates.channel": "Canal",
|
|
92
|
+
"notifications.templates.purpose": "Tipo",
|
|
93
|
+
"notifications.templates.subject": "Assunto",
|
|
94
|
+
"notifications.templates.body": "Texto",
|
|
95
|
+
"notifications.templates.bodyPlaceholder": "Oi {{nome}}! \u2026",
|
|
96
|
+
"notifications.templates.active": "Ativo",
|
|
97
|
+
"notifications.templates.varsHint": "Use {{nome}} para encaixar dados do envio.",
|
|
98
|
+
"notifications.templates.varsFound": "Campos usados: {{vars}}",
|
|
99
|
+
"notifications.templates.keyRequired": "O modelo precisa de uma chave.",
|
|
100
|
+
"notifications.templates.bodyRequired": "O modelo precisa de um texto.",
|
|
101
|
+
"notifications.templates.saveFailed": "N\xE3o foi poss\xEDvel salvar o modelo.",
|
|
102
|
+
"notifications.templates.emptyTitle": "Nenhum modelo.",
|
|
103
|
+
"notifications.templates.emptyHint": "Sem modelo n\xE3o h\xE1 o que enviar: o motor tem fila, tentativa e registro de erro, mas nada para escrever na mensagem.",
|
|
104
|
+
"notifications.templates.deleteTitle": "Excluir modelo",
|
|
105
|
+
"notifications.templates.deleteDescription": '"{{key}}" deixa de existir. Disparos que pedirem esta chave n\xE3o v\xE3o encontrar nada.',
|
|
106
|
+
"notifications.history.title": "Hist\xF3rico de envios",
|
|
107
|
+
"notifications.history.subtitle": "O que saiu, o que falhou e por qu\xEA.",
|
|
108
|
+
"notifications.history.total": "Disparos",
|
|
109
|
+
"notifications.history.sent": "Enviados",
|
|
110
|
+
"notifications.history.failed": "Falhas",
|
|
111
|
+
"notifications.history.blocked": "Bloqueados",
|
|
112
|
+
"notifications.history.topReason": "{{count}} mensagens n\xE3o chegaram pelo MESMO motivo: {{reason}}.",
|
|
113
|
+
"notifications.history.allChannels": "Todos os canais",
|
|
114
|
+
"notifications.history.allStatuses": "Todos os status",
|
|
115
|
+
"notifications.history.when": "Data/Hora",
|
|
116
|
+
"notifications.history.template": "Modelo",
|
|
117
|
+
"notifications.history.who": "Quem",
|
|
118
|
+
"notifications.history.channel": "Canal",
|
|
119
|
+
"notifications.history.status": "Status",
|
|
120
|
+
"notifications.history.reason": "Motivo",
|
|
121
|
+
"notifications.history.attempts": "{{n}} tentativas",
|
|
122
|
+
"notifications.history.empty": "Nenhum envio no per\xEDodo.",
|
|
123
|
+
"notifications.channel.whatsapp": "WhatsApp",
|
|
124
|
+
"notifications.channel.email": "E-mail",
|
|
125
|
+
"notifications.channel.sms": "SMS",
|
|
126
|
+
"notifications.channel.push": "Push",
|
|
127
|
+
"notifications.channel.in_app": "No app",
|
|
128
|
+
"notifications.status.sent": "Enviado",
|
|
129
|
+
"notifications.status.failed": "Falhou",
|
|
130
|
+
"notifications.status.blocked": "Bloqueado",
|
|
131
|
+
"notifications.status.pending": "Na fila",
|
|
132
|
+
"notifications.status.abandoned": "Desistiu",
|
|
133
|
+
"notifications.status.cancelled": "Cancelado",
|
|
134
|
+
"notifications.purpose.transactional": "Aviso",
|
|
135
|
+
"notifications.purpose.marketing": "Divulga\xE7\xE3o"
|
|
23
136
|
};
|
|
24
137
|
|
|
25
138
|
// src/locales/index.ts
|
|
@@ -1077,13 +1190,858 @@ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
|
1077
1190
|
--$stmt$;
|
|
1078
1191
|
END $dp$;
|
|
1079
1192
|
`;
|
|
1193
|
+
var MIGRATION_001_O_HISTORICO_DIZ_POR_QUE_NAO_CHEGOU = `-- 001_o_historico_diz_por_que_nao_chegou.sql
|
|
1194
|
+
--
|
|
1195
|
+
-- "Meus lembretes n\xE3o est\xE3o chegando" \xE9 a pergunta que o V1 responde e o V2
|
|
1196
|
+
-- n\xE3o. L\xE1 existe uma tela de Hist\xF3rico de Disparos com tr\xEAs totais e uma
|
|
1197
|
+
-- coluna que, nas linhas de falha, traz o MOTIVO em vez do texto enviado. Na
|
|
1198
|
+
-- verifica\xE7\xE3o do guia: 500 disparos, 390 enviados, **110 falhas** \u2014 todas pelo
|
|
1199
|
+
-- mesmo motivo, template n\xE3o aprovado pela Meta. Sem essa tela, ningu\xE9m teria
|
|
1200
|
+
-- descoberto isso; a queixa continuaria sendo "o sistema n\xE3o avisa o cliente".
|
|
1201
|
+
--
|
|
1202
|
+
-- Aqui o motor j\xE1 registrava tudo o que a resposta pede \u2014 \`last_error\`,
|
|
1203
|
+
-- \`blocked_reason\`, \`attempts\`, e a \`error\` de cada tentativa de entrega. O que
|
|
1204
|
+
-- n\xE3o existia era **uma leitura por mensagem**: a \xFAnica view do plugin
|
|
1205
|
+
-- (\`v_notification_queue\`) \xE9 agregada, boa para saber se a fila est\xE1 saud\xE1vel
|
|
1206
|
+
-- e in\xFAtil para saber por que a Maria n\xE3o recebeu.
|
|
1207
|
+
--
|
|
1208
|
+
-- E CORRIGE UM DEFEITO DO V1 DE PROP\xD3SITO. L\xE1, toda linha de falha mostra
|
|
1209
|
+
-- \`Cliente #null\` \u2014 o registro perde a refer\xEAncia de quem era, ent\xE3o a tela
|
|
1210
|
+
-- diz que 110 pessoas ficaram sem aviso e n\xE3o diz QUAIS. Aqui o nome vem por
|
|
1211
|
+
-- jun\xE7\xE3o com \`people\`: uma lista de falhas que n\xE3o nomeia ningu\xE9m n\xE3o permite
|
|
1212
|
+
-- ligar para ningu\xE9m, e ligar \xE9 a \xFAnica coisa \xFAtil a fazer com ela.
|
|
1213
|
+
--
|
|
1214
|
+
-- MOTIVO TEM PRECED\xCANCIA, E A ORDEM IMPORTA: \`blocked_reason\` primeiro. Uma
|
|
1215
|
+
-- mensagem bloqueada (o destinat\xE1rio optou por n\xE3o receber, est\xE1 em hor\xE1rio de
|
|
1216
|
+
-- sil\xEAncio, n\xE3o h\xE1 canal) nunca chegou a ser tentada, e mostrar o erro da
|
|
1217
|
+
-- \xFAltima tentativa ali diria que o envio falhou quando ele nem come\xE7ou.
|
|
1218
|
+
|
|
1219
|
+
CREATE OR REPLACE VIEW public.v_notification_history WITH (security_invoker='true') AS
|
|
1220
|
+
SELECT
|
|
1221
|
+
q.id,
|
|
1222
|
+
q.tenant_id,
|
|
1223
|
+
q.unit_id,
|
|
1224
|
+
q.template_key,
|
|
1225
|
+
q.channel,
|
|
1226
|
+
q.status,
|
|
1227
|
+
q.purpose,
|
|
1228
|
+
q.locale,
|
|
1229
|
+
q.scheduled_for,
|
|
1230
|
+
q.created_at,
|
|
1231
|
+
q.attempts,
|
|
1232
|
+
q.max_attempts,
|
|
1233
|
+
q.person_id,
|
|
1234
|
+
-- O que o V1 perde. Sem nome, a lista de falhas \xE9 um n\xFAmero.
|
|
1235
|
+
p.name AS person_name,
|
|
1236
|
+
p.phone AS person_phone,
|
|
1237
|
+
p.email AS person_email,
|
|
1238
|
+
q.subject,
|
|
1239
|
+
q.body,
|
|
1240
|
+
-- Bloqueado antes de tentar vem primeiro: o erro da \xFAltima tentativa ali
|
|
1241
|
+
-- diria que o envio falhou quando ele nem chegou a come\xE7ar.
|
|
1242
|
+
coalesce(q.blocked_reason, q.last_error, d.error) AS reason,
|
|
1243
|
+
d.provider,
|
|
1244
|
+
d.provider_ref,
|
|
1245
|
+
d.delivered_at,
|
|
1246
|
+
d.failed_at
|
|
1247
|
+
FROM public.plg_notifications_queue q
|
|
1248
|
+
LEFT JOIN public.people p ON p.id = q.person_id
|
|
1249
|
+
-- A \xDALTIMA tentativa, n\xE3o todas: uma mensagem com cinco tentativas viraria
|
|
1250
|
+
-- cinco linhas no hist\xF3rico, e o lojista conta cinco falhas onde houve uma
|
|
1251
|
+
-- mensagem.
|
|
1252
|
+
LEFT JOIN LATERAL (
|
|
1253
|
+
SELECT x.error, x.provider, x.provider_ref, x.delivered_at, x.failed_at
|
|
1254
|
+
FROM public.plg_notifications_deliveries x
|
|
1255
|
+
WHERE x.queue_id = q.id
|
|
1256
|
+
ORDER BY x.created_at DESC
|
|
1257
|
+
LIMIT 1
|
|
1258
|
+
) d ON true;
|
|
1259
|
+
|
|
1260
|
+
COMMENT ON VIEW public.v_notification_history IS
|
|
1261
|
+
'Uma linha por mensagem, com o MOTIVO de n\xE3o ter chegado e o NOME de quem ficou sem \u2014 o V1 mostra "Cliente #null" em toda linha de falha (notifications/001).';
|
|
1262
|
+
|
|
1263
|
+
GRANT SELECT ON public.v_notification_history TO authenticated;
|
|
1264
|
+
GRANT SELECT ON public.v_notification_history TO service_role;
|
|
1265
|
+
|
|
1266
|
+
-- \u2500\u2500 os tr\xEAs totais do topo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1267
|
+
--
|
|
1268
|
+
-- O V1 mostra Total / Enviados / Falhas. Aqui s\xE3o quatro, porque BLOQUEADO n\xE3o
|
|
1269
|
+
-- \xE9 FALHA: a mensagem que n\xE3o saiu porque a pessoa pediu para n\xE3o receber \xE9 o
|
|
1270
|
+
-- sistema funcionando, e som\xE1-la \xE0s falhas faria o lojista ca\xE7ar um defeito que
|
|
1271
|
+
-- n\xE3o existe.
|
|
1272
|
+
CREATE OR REPLACE FUNCTION public.notifications_history_summary(p_days integer DEFAULT 30)
|
|
1273
|
+
RETURNS jsonb
|
|
1274
|
+
LANGUAGE plpgsql
|
|
1275
|
+
SECURITY DEFINER
|
|
1276
|
+
STABLE
|
|
1277
|
+
SET search_path TO ''
|
|
1278
|
+
AS $$
|
|
1279
|
+
DECLARE
|
|
1280
|
+
v_tenant uuid := app.current_tenant_id();
|
|
1281
|
+
v_out jsonb;
|
|
1282
|
+
BEGIN
|
|
1283
|
+
IF v_tenant IS NULL THEN
|
|
1284
|
+
RAISE EXCEPTION 'notifications_history_summary: sem tenant na sess\xE3o' USING ERRCODE = '42501';
|
|
1285
|
+
END IF;
|
|
1286
|
+
IF NOT app.has_permission('notifications.read', NULL::uuid) THEN
|
|
1287
|
+
RAISE EXCEPTION 'notifications_history_summary: exige notifications.read' USING ERRCODE = '42501';
|
|
1288
|
+
END IF;
|
|
1289
|
+
|
|
1290
|
+
SELECT jsonb_build_object(
|
|
1291
|
+
'total', count(*),
|
|
1292
|
+
'sent', count(*) FILTER (WHERE h.status = 'sent'),
|
|
1293
|
+
'failed', count(*) FILTER (WHERE h.status IN ('failed', 'abandoned')),
|
|
1294
|
+
'blocked', count(*) FILTER (WHERE h.status = 'blocked'),
|
|
1295
|
+
'pending', count(*) FILTER (WHERE h.status = 'pending'),
|
|
1296
|
+
'days', p_days,
|
|
1297
|
+
-- O motivo mais comum, e quantas vezes. \xC9 o que transforma "110 falhas" em
|
|
1298
|
+
-- "110 falhas pela MESMA raz\xE3o" \u2014 que \xE9 a diferen\xE7a entre um problema e
|
|
1299
|
+
-- cento e dez.
|
|
1300
|
+
'topReason', (
|
|
1301
|
+
SELECT jsonb_build_object('reason', r.reason, 'count', r.n)
|
|
1302
|
+
FROM (
|
|
1303
|
+
SELECT coalesce(x.reason, '\u2014') AS reason, count(*) AS n
|
|
1304
|
+
FROM public.v_notification_history x
|
|
1305
|
+
WHERE x.tenant_id = v_tenant
|
|
1306
|
+
AND x.status IN ('failed', 'abandoned', 'blocked')
|
|
1307
|
+
AND x.created_at >= now() - make_interval(days => p_days)
|
|
1308
|
+
GROUP BY 1 ORDER BY n DESC LIMIT 1
|
|
1309
|
+
) r)
|
|
1310
|
+
) INTO v_out
|
|
1311
|
+
FROM public.v_notification_history h
|
|
1312
|
+
WHERE h.tenant_id = v_tenant
|
|
1313
|
+
AND h.created_at >= now() - make_interval(days => p_days);
|
|
1314
|
+
|
|
1315
|
+
RETURN v_out;
|
|
1316
|
+
END $$;
|
|
1317
|
+
|
|
1318
|
+
COMMENT ON FUNCTION public.notifications_history_summary(integer) IS
|
|
1319
|
+
'Total, enviados, falhas, bloqueados e o motivo mais comum. Bloqueado N\xC3O conta como falha \u2014 \xE9 o sistema funcionando (notifications/001).';
|
|
1320
|
+
|
|
1321
|
+
REVOKE ALL ON FUNCTION public.notifications_history_summary(integer) FROM PUBLIC, anon;
|
|
1322
|
+
GRANT EXECUTE ON FUNCTION public.notifications_history_summary(integer) TO authenticated;
|
|
1323
|
+
GRANT EXECUTE ON FUNCTION public.notifications_history_summary(integer) TO service_role;
|
|
1324
|
+
`;
|
|
1080
1325
|
var MIGRATIONS = [
|
|
1081
|
-
{ id: "000_baseline", sql: MIGRATION_000_BASELINE }
|
|
1326
|
+
{ id: "000_baseline", sql: MIGRATION_000_BASELINE },
|
|
1327
|
+
{ id: "001_o_historico_diz_por_que_nao_chegou", sql: MIGRATION_001_O_HISTORICO_DIZ_POR_QUE_NAO_CHEGOU }
|
|
1328
|
+
];
|
|
1329
|
+
|
|
1330
|
+
// src/data/tables.ts
|
|
1331
|
+
var T = {
|
|
1332
|
+
templates: "plg_notifications_templates",
|
|
1333
|
+
history: "v_notification_history"
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
// src/data/supabase.ts
|
|
1337
|
+
function client() {
|
|
1338
|
+
const c = getSupabaseClientOptional();
|
|
1339
|
+
if (!c) throw new Error("Supabase client not initialised");
|
|
1340
|
+
return c;
|
|
1341
|
+
}
|
|
1342
|
+
function toTemplate(r) {
|
|
1343
|
+
return {
|
|
1344
|
+
id: r.id,
|
|
1345
|
+
key: r.key,
|
|
1346
|
+
channel: r.channel,
|
|
1347
|
+
locale: r.locale ?? "pt-BR",
|
|
1348
|
+
purpose: r.purpose ?? "transactional",
|
|
1349
|
+
subject: r.subject ?? void 0,
|
|
1350
|
+
body: r.body ?? "",
|
|
1351
|
+
variables: Array.isArray(r.variables) ? r.variables : [],
|
|
1352
|
+
isDefault: r.is_default === true,
|
|
1353
|
+
isActive: r.is_active !== false,
|
|
1354
|
+
tenantId: r.tenant_id,
|
|
1355
|
+
updatedAt: r.updated_at ?? r.created_at
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
function createSupabaseNotificationsProvider() {
|
|
1359
|
+
return {
|
|
1360
|
+
async getTemplates() {
|
|
1361
|
+
const pub = client();
|
|
1362
|
+
const tenantId = getActiveTenantId();
|
|
1363
|
+
if (!tenantId) return [];
|
|
1364
|
+
const { data } = await pub.from(T.templates).select("*").eq("tenant_id", tenantId).order("key").order("channel");
|
|
1365
|
+
return (data ?? []).map(toTemplate);
|
|
1366
|
+
},
|
|
1367
|
+
async saveTemplate(input) {
|
|
1368
|
+
const pub = client();
|
|
1369
|
+
const tenantId = getActiveTenantId();
|
|
1370
|
+
if (!tenantId) throw new Error("Sem tenant ativo.");
|
|
1371
|
+
const row = {
|
|
1372
|
+
tenant_id: tenantId,
|
|
1373
|
+
key: input.key.trim(),
|
|
1374
|
+
channel: input.channel,
|
|
1375
|
+
locale: input.locale ?? "pt-BR",
|
|
1376
|
+
purpose: input.purpose ?? "transactional",
|
|
1377
|
+
subject: input.subject?.trim() || null,
|
|
1378
|
+
body: input.body,
|
|
1379
|
+
variables: input.variables ?? []
|
|
1380
|
+
};
|
|
1381
|
+
if (input.isActive !== void 0) row.is_active = input.isActive;
|
|
1382
|
+
if (input.id) {
|
|
1383
|
+
const { data: data2, error: error2 } = await pub.from(T.templates).update(row).eq("id", input.id).select("*").single();
|
|
1384
|
+
if (error2) throw new Error(error2.message);
|
|
1385
|
+
return toTemplate(data2);
|
|
1386
|
+
}
|
|
1387
|
+
const { data, error } = await pub.from(T.templates).insert(row).select("*").single();
|
|
1388
|
+
if (error) throw new Error(error.message);
|
|
1389
|
+
return toTemplate(data);
|
|
1390
|
+
},
|
|
1391
|
+
async deleteTemplate(id) {
|
|
1392
|
+
const pub = client();
|
|
1393
|
+
const { error } = await pub.from(T.templates).delete().eq("id", id);
|
|
1394
|
+
if (error) throw new Error(error.message);
|
|
1395
|
+
},
|
|
1396
|
+
async getHistory(query) {
|
|
1397
|
+
const pub = client();
|
|
1398
|
+
const tenantId = getActiveTenantId();
|
|
1399
|
+
if (!tenantId) return [];
|
|
1400
|
+
let qb = pub.from(T.history).select("*").eq("tenant_id", tenantId);
|
|
1401
|
+
if (query?.channel) qb = qb.eq("channel", query.channel);
|
|
1402
|
+
if (query?.status) qb = qb.eq("status", query.status);
|
|
1403
|
+
if (query?.days) {
|
|
1404
|
+
qb = qb.gte("created_at", new Date(Date.now() - query.days * 864e5).toISOString());
|
|
1405
|
+
}
|
|
1406
|
+
const { data } = await qb.order("created_at", { ascending: false }).limit(query?.limit ?? 200);
|
|
1407
|
+
return (data ?? []).map((r) => ({
|
|
1408
|
+
id: r.id,
|
|
1409
|
+
templateKey: r.template_key,
|
|
1410
|
+
channel: r.channel,
|
|
1411
|
+
status: r.status,
|
|
1412
|
+
purpose: r.purpose ?? "transactional",
|
|
1413
|
+
scheduledFor: r.scheduled_for ?? void 0,
|
|
1414
|
+
createdAt: r.created_at,
|
|
1415
|
+
attempts: Number(r.attempts ?? 0),
|
|
1416
|
+
maxAttempts: Number(r.max_attempts ?? 0),
|
|
1417
|
+
personId: r.person_id ?? void 0,
|
|
1418
|
+
personName: r.person_name ?? void 0,
|
|
1419
|
+
personPhone: r.person_phone ?? void 0,
|
|
1420
|
+
subject: r.subject ?? void 0,
|
|
1421
|
+
body: r.body ?? void 0,
|
|
1422
|
+
reason: r.reason ?? void 0,
|
|
1423
|
+
provider: r.provider ?? void 0,
|
|
1424
|
+
deliveredAt: r.delivered_at ?? void 0,
|
|
1425
|
+
failedAt: r.failed_at ?? void 0
|
|
1426
|
+
}));
|
|
1427
|
+
},
|
|
1428
|
+
async getHistorySummary(days = 30) {
|
|
1429
|
+
const pub = client();
|
|
1430
|
+
const { data, error } = await pub.rpc("notifications_history_summary", { p_days: days });
|
|
1431
|
+
if (error) throw new Error(error.message);
|
|
1432
|
+
const d = data ?? {};
|
|
1433
|
+
return {
|
|
1434
|
+
total: Number(d.total ?? 0),
|
|
1435
|
+
sent: Number(d.sent ?? 0),
|
|
1436
|
+
failed: Number(d.failed ?? 0),
|
|
1437
|
+
blocked: Number(d.blocked ?? 0),
|
|
1438
|
+
pending: Number(d.pending ?? 0),
|
|
1439
|
+
days: Number(d.days ?? days),
|
|
1440
|
+
topReason: d.topReason?.reason ? { reason: String(d.topReason.reason), count: Number(d.topReason.count ?? 0) } : void 0
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// src/data/mock.ts
|
|
1447
|
+
var seq = 1;
|
|
1448
|
+
var uid = () => `mock-${seq++}`;
|
|
1449
|
+
var ago = (h) => new Date(Date.now() - h * 36e5).toISOString();
|
|
1450
|
+
var templates = [
|
|
1451
|
+
{
|
|
1452
|
+
id: uid(),
|
|
1453
|
+
key: "order_completed",
|
|
1454
|
+
channel: "whatsapp",
|
|
1455
|
+
locale: "pt-BR",
|
|
1456
|
+
purpose: "transactional",
|
|
1457
|
+
body: "Oi {{nome}}! Seu pedido saiu. Obrigado pela prefer\xEAncia.",
|
|
1458
|
+
variables: ["nome"],
|
|
1459
|
+
isDefault: true,
|
|
1460
|
+
isActive: true,
|
|
1461
|
+
tenantId: "mock",
|
|
1462
|
+
updatedAt: ago(72)
|
|
1463
|
+
},
|
|
1464
|
+
{
|
|
1465
|
+
id: uid(),
|
|
1466
|
+
key: "order_completed",
|
|
1467
|
+
channel: "email",
|
|
1468
|
+
locale: "pt-BR",
|
|
1469
|
+
purpose: "transactional",
|
|
1470
|
+
subject: "Seu pedido saiu",
|
|
1471
|
+
body: "Oi {{nome}}, seu pedido saiu.",
|
|
1472
|
+
variables: ["nome"],
|
|
1473
|
+
isDefault: false,
|
|
1474
|
+
isActive: true,
|
|
1475
|
+
tenantId: "mock",
|
|
1476
|
+
updatedAt: ago(72)
|
|
1477
|
+
},
|
|
1478
|
+
{
|
|
1479
|
+
id: uid(),
|
|
1480
|
+
key: "birthday",
|
|
1481
|
+
channel: "whatsapp",
|
|
1482
|
+
locale: "pt-BR",
|
|
1483
|
+
purpose: "marketing",
|
|
1484
|
+
body: "Feliz anivers\xE1rio, {{nome}}! Passa aqui que tem mimo.",
|
|
1485
|
+
variables: ["nome"],
|
|
1486
|
+
isDefault: true,
|
|
1487
|
+
isActive: false,
|
|
1488
|
+
tenantId: "mock",
|
|
1489
|
+
updatedAt: ago(400)
|
|
1490
|
+
}
|
|
1491
|
+
];
|
|
1492
|
+
var history = [
|
|
1493
|
+
{
|
|
1494
|
+
id: uid(),
|
|
1495
|
+
templateKey: "order_completed",
|
|
1496
|
+
channel: "whatsapp",
|
|
1497
|
+
status: "sent",
|
|
1498
|
+
purpose: "transactional",
|
|
1499
|
+
createdAt: ago(2),
|
|
1500
|
+
attempts: 1,
|
|
1501
|
+
maxAttempts: 5,
|
|
1502
|
+
personName: "Maria Silva",
|
|
1503
|
+
personPhone: "(11) 99999-0001",
|
|
1504
|
+
provider: "tyxter",
|
|
1505
|
+
deliveredAt: ago(2)
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
id: uid(),
|
|
1509
|
+
templateKey: "order_completed",
|
|
1510
|
+
channel: "whatsapp",
|
|
1511
|
+
status: "failed",
|
|
1512
|
+
purpose: "transactional",
|
|
1513
|
+
createdAt: ago(3),
|
|
1514
|
+
attempts: 5,
|
|
1515
|
+
maxAttempts: 5,
|
|
1516
|
+
personName: "Jo\xE3o Pereira",
|
|
1517
|
+
personPhone: "(11) 99999-0002",
|
|
1518
|
+
reason: "template n\xE3o aprovado pelo provedor",
|
|
1519
|
+
failedAt: ago(3)
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
id: uid(),
|
|
1523
|
+
templateKey: "order_completed",
|
|
1524
|
+
channel: "whatsapp",
|
|
1525
|
+
status: "failed",
|
|
1526
|
+
purpose: "transactional",
|
|
1527
|
+
createdAt: ago(5),
|
|
1528
|
+
attempts: 5,
|
|
1529
|
+
maxAttempts: 5,
|
|
1530
|
+
personName: "Ana Costa",
|
|
1531
|
+
personPhone: "(11) 99999-0003",
|
|
1532
|
+
reason: "template n\xE3o aprovado pelo provedor",
|
|
1533
|
+
failedAt: ago(5)
|
|
1534
|
+
},
|
|
1535
|
+
{
|
|
1536
|
+
id: uid(),
|
|
1537
|
+
templateKey: "birthday",
|
|
1538
|
+
channel: "whatsapp",
|
|
1539
|
+
status: "blocked",
|
|
1540
|
+
purpose: "marketing",
|
|
1541
|
+
createdAt: ago(20),
|
|
1542
|
+
attempts: 0,
|
|
1543
|
+
maxAttempts: 5,
|
|
1544
|
+
personName: "Carlos Dias",
|
|
1545
|
+
reason: "destinat\xE1rio optou por n\xE3o receber marketing"
|
|
1546
|
+
},
|
|
1547
|
+
{
|
|
1548
|
+
id: uid(),
|
|
1549
|
+
templateKey: "order_completed",
|
|
1550
|
+
channel: "email",
|
|
1551
|
+
status: "pending",
|
|
1552
|
+
purpose: "transactional",
|
|
1553
|
+
createdAt: ago(1),
|
|
1554
|
+
attempts: 0,
|
|
1555
|
+
maxAttempts: 5,
|
|
1556
|
+
personName: "Beatriz Lima"
|
|
1557
|
+
}
|
|
1082
1558
|
];
|
|
1559
|
+
function createMockNotificationsProvider() {
|
|
1560
|
+
return {
|
|
1561
|
+
async getTemplates() {
|
|
1562
|
+
return [...templates];
|
|
1563
|
+
},
|
|
1564
|
+
async saveTemplate(input) {
|
|
1565
|
+
if (input.id) {
|
|
1566
|
+
const found = templates.find((t) => t.id === input.id);
|
|
1567
|
+
if (!found) throw new Error("Modelo n\xE3o encontrado.");
|
|
1568
|
+
Object.assign(found, {
|
|
1569
|
+
key: input.key.trim(),
|
|
1570
|
+
channel: input.channel,
|
|
1571
|
+
locale: input.locale ?? found.locale,
|
|
1572
|
+
purpose: input.purpose ?? found.purpose,
|
|
1573
|
+
subject: input.subject,
|
|
1574
|
+
body: input.body,
|
|
1575
|
+
variables: input.variables ?? found.variables,
|
|
1576
|
+
isActive: input.isActive ?? found.isActive,
|
|
1577
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1578
|
+
});
|
|
1579
|
+
return found;
|
|
1580
|
+
}
|
|
1581
|
+
const created = {
|
|
1582
|
+
id: uid(),
|
|
1583
|
+
key: input.key.trim(),
|
|
1584
|
+
channel: input.channel,
|
|
1585
|
+
locale: input.locale ?? "pt-BR",
|
|
1586
|
+
purpose: input.purpose ?? "transactional",
|
|
1587
|
+
subject: input.subject,
|
|
1588
|
+
body: input.body,
|
|
1589
|
+
variables: input.variables ?? [],
|
|
1590
|
+
isDefault: false,
|
|
1591
|
+
isActive: input.isActive ?? true,
|
|
1592
|
+
tenantId: "mock",
|
|
1593
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1594
|
+
};
|
|
1595
|
+
templates.push(created);
|
|
1596
|
+
return created;
|
|
1597
|
+
},
|
|
1598
|
+
async deleteTemplate(id) {
|
|
1599
|
+
const i = templates.findIndex((t) => t.id === id);
|
|
1600
|
+
if (i >= 0) templates.splice(i, 1);
|
|
1601
|
+
},
|
|
1602
|
+
async getHistory(query) {
|
|
1603
|
+
return history.filter((h) => !query?.channel || h.channel === query.channel).filter((h) => !query?.status || h.status === query.status).slice(0, query?.limit ?? 200);
|
|
1604
|
+
},
|
|
1605
|
+
async getHistorySummary(days = 30) {
|
|
1606
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
1607
|
+
for (const h of history) {
|
|
1608
|
+
if (h.reason) reasons.set(h.reason, (reasons.get(h.reason) ?? 0) + 1);
|
|
1609
|
+
}
|
|
1610
|
+
const top = [...reasons.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
1611
|
+
return {
|
|
1612
|
+
total: history.length,
|
|
1613
|
+
sent: history.filter((h) => h.status === "sent").length,
|
|
1614
|
+
failed: history.filter((h) => h.status === "failed" || h.status === "abandoned").length,
|
|
1615
|
+
blocked: history.filter((h) => h.status === "blocked").length,
|
|
1616
|
+
pending: history.filter((h) => h.status === "pending").length,
|
|
1617
|
+
days,
|
|
1618
|
+
topReason: top ? { reason: top[0], count: top[1] } : void 0
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
var Ctx = React2.createContext(null);
|
|
1624
|
+
function NotificationsContextProvider({ provider, children }) {
|
|
1625
|
+
return /* @__PURE__ */ jsx(Ctx.Provider, { value: provider, children });
|
|
1626
|
+
}
|
|
1627
|
+
function useNotificationsProvider() {
|
|
1628
|
+
const v = React2.useContext(Ctx);
|
|
1629
|
+
if (!v) throw new Error("useNotificationsProvider fora do NotificationsContextProvider");
|
|
1630
|
+
return v;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// src/types.ts
|
|
1634
|
+
var MESSAGE_CHANNELS = ["whatsapp", "email", "sms", "push", "in_app"];
|
|
1635
|
+
function extractVariables(body, subject) {
|
|
1636
|
+
const found = /* @__PURE__ */ new Set();
|
|
1637
|
+
for (const text of [body, subject ?? ""]) {
|
|
1638
|
+
for (const m of text.matchAll(/\{\{\s*([a-z0-9_]+)\s*\}\}/gi)) found.add(m[1].toLowerCase());
|
|
1639
|
+
}
|
|
1640
|
+
return [...found].sort();
|
|
1641
|
+
}
|
|
1642
|
+
function TemplateEditorDialog({ open, template, provider, onClose, onSaved }) {
|
|
1643
|
+
const t = useTranslation();
|
|
1644
|
+
const [key, setKey] = React2.useState("");
|
|
1645
|
+
const [channel, setChannel] = React2.useState("whatsapp");
|
|
1646
|
+
const [purpose, setPurpose] = React2.useState("transactional");
|
|
1647
|
+
const [subject, setSubject] = React2.useState("");
|
|
1648
|
+
const [body, setBody] = React2.useState("");
|
|
1649
|
+
const [isActive, setIsActive] = React2.useState(true);
|
|
1650
|
+
const [saving, setSaving] = React2.useState(false);
|
|
1651
|
+
const [error, setError] = React2.useState(null);
|
|
1652
|
+
React2.useEffect(() => {
|
|
1653
|
+
if (!open) return;
|
|
1654
|
+
setKey(template?.key ?? "");
|
|
1655
|
+
setChannel(template?.channel ?? "whatsapp");
|
|
1656
|
+
setPurpose(template?.purpose ?? "transactional");
|
|
1657
|
+
setSubject(template?.subject ?? "");
|
|
1658
|
+
setBody(template?.body ?? "");
|
|
1659
|
+
setIsActive(template?.isActive ?? true);
|
|
1660
|
+
setError(null);
|
|
1661
|
+
}, [open, template]);
|
|
1662
|
+
const variables = extractVariables(body, subject);
|
|
1663
|
+
const needsSubject = channel === "email";
|
|
1664
|
+
async function save() {
|
|
1665
|
+
if (!key.trim()) {
|
|
1666
|
+
setError(t("notifications.templates.keyRequired"));
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
if (!body.trim()) {
|
|
1670
|
+
setError(t("notifications.templates.bodyRequired"));
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
setSaving(true);
|
|
1674
|
+
setError(null);
|
|
1675
|
+
try {
|
|
1676
|
+
await provider.saveTemplate({
|
|
1677
|
+
id: template?.id,
|
|
1678
|
+
key: key.trim(),
|
|
1679
|
+
channel,
|
|
1680
|
+
purpose,
|
|
1681
|
+
subject: needsSubject ? subject.trim() : void 0,
|
|
1682
|
+
body,
|
|
1683
|
+
variables,
|
|
1684
|
+
isActive
|
|
1685
|
+
});
|
|
1686
|
+
onSaved();
|
|
1687
|
+
onClose();
|
|
1688
|
+
} catch (e) {
|
|
1689
|
+
setError(e?.message ?? t("notifications.templates.saveFailed"));
|
|
1690
|
+
} finally {
|
|
1691
|
+
setSaving(false);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
return /* @__PURE__ */ jsx(Modal, { open, onOpenChange: (v) => {
|
|
1695
|
+
if (!v) onClose();
|
|
1696
|
+
}, children: /* @__PURE__ */ jsxs(ModalContent, { className: "sm:max-w-xl", children: [
|
|
1697
|
+
/* @__PURE__ */ jsxs(ModalHeader, { children: [
|
|
1698
|
+
/* @__PURE__ */ jsx(ModalTitle, { children: template ? t("notifications.templates.editTitle") : t("notifications.templates.newTitle") }),
|
|
1699
|
+
/* @__PURE__ */ jsx(ModalDescription, { children: t("notifications.templates.editorDescription") })
|
|
1700
|
+
] }),
|
|
1701
|
+
/* @__PURE__ */ jsxs(ModalBody, { className: "space-y-3", children: [
|
|
1702
|
+
/* @__PURE__ */ jsxs("div", { className: "grid gap-3 sm:grid-cols-3", children: [
|
|
1703
|
+
/* @__PURE__ */ jsxs("div", { className: "sm:col-span-1", children: [
|
|
1704
|
+
/* @__PURE__ */ jsx("label", { className: "text-xs font-medium", children: t("notifications.templates.key") }),
|
|
1705
|
+
/* @__PURE__ */ jsx(
|
|
1706
|
+
Input,
|
|
1707
|
+
{
|
|
1708
|
+
value: key,
|
|
1709
|
+
disabled: Boolean(template),
|
|
1710
|
+
onChange: (e) => setKey(e.target.value),
|
|
1711
|
+
placeholder: "order_completed"
|
|
1712
|
+
}
|
|
1713
|
+
)
|
|
1714
|
+
] }),
|
|
1715
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
1716
|
+
/* @__PURE__ */ jsx("label", { className: "text-xs font-medium", children: t("notifications.templates.channel") }),
|
|
1717
|
+
/* @__PURE__ */ jsxs(Select, { value: channel, onValueChange: (v) => setChannel(v), children: [
|
|
1718
|
+
/* @__PURE__ */ jsx(SelectTrigger, { className: "h-9 text-sm", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
|
|
1719
|
+
/* @__PURE__ */ jsx(SelectContent, { children: MESSAGE_CHANNELS.map((c) => /* @__PURE__ */ jsx(SelectItem, { value: c, children: t(`notifications.channel.${c}`) }, c)) })
|
|
1720
|
+
] })
|
|
1721
|
+
] }),
|
|
1722
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
1723
|
+
/* @__PURE__ */ jsx("label", { className: "text-xs font-medium", children: t("notifications.templates.purpose") }),
|
|
1724
|
+
/* @__PURE__ */ jsxs(Select, { value: purpose, onValueChange: (v) => setPurpose(v), children: [
|
|
1725
|
+
/* @__PURE__ */ jsx(SelectTrigger, { className: "h-9 text-sm", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
|
|
1726
|
+
/* @__PURE__ */ jsxs(SelectContent, { children: [
|
|
1727
|
+
/* @__PURE__ */ jsx(SelectItem, { value: "transactional", children: t("notifications.purpose.transactional") }),
|
|
1728
|
+
/* @__PURE__ */ jsx(SelectItem, { value: "marketing", children: t("notifications.purpose.marketing") })
|
|
1729
|
+
] })
|
|
1730
|
+
] })
|
|
1731
|
+
] })
|
|
1732
|
+
] }),
|
|
1733
|
+
needsSubject && /* @__PURE__ */ jsxs("div", { children: [
|
|
1734
|
+
/* @__PURE__ */ jsx("label", { className: "text-xs font-medium", children: t("notifications.templates.subject") }),
|
|
1735
|
+
/* @__PURE__ */ jsx(Input, { value: subject, onChange: (e) => setSubject(e.target.value) })
|
|
1736
|
+
] }),
|
|
1737
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
1738
|
+
/* @__PURE__ */ jsx("label", { className: "text-xs font-medium", children: t("notifications.templates.body") }),
|
|
1739
|
+
/* @__PURE__ */ jsx(
|
|
1740
|
+
"textarea",
|
|
1741
|
+
{
|
|
1742
|
+
className: "min-h-[120px] w-full rounded-md border bg-background px-3 py-2 text-sm",
|
|
1743
|
+
value: body,
|
|
1744
|
+
onChange: (e) => setBody(e.target.value),
|
|
1745
|
+
placeholder: t("notifications.templates.bodyPlaceholder")
|
|
1746
|
+
}
|
|
1747
|
+
),
|
|
1748
|
+
/* @__PURE__ */ jsx("p", { className: "mt-1 text-[10px] text-muted-foreground", children: variables.length > 0 ? t("notifications.templates.varsFound", { vars: variables.map((v) => `{{${v}}}`).join(" ") }) : t("notifications.templates.varsHint") })
|
|
1749
|
+
] }),
|
|
1750
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
1751
|
+
/* @__PURE__ */ jsx(Switch, { checked: isActive, onCheckedChange: setIsActive }),
|
|
1752
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs", children: t("notifications.templates.active") })
|
|
1753
|
+
] }),
|
|
1754
|
+
error && /* @__PURE__ */ jsx("p", { className: "text-xs text-destructive", children: error })
|
|
1755
|
+
] }),
|
|
1756
|
+
/* @__PURE__ */ jsxs(ModalFooter, { children: [
|
|
1757
|
+
/* @__PURE__ */ jsx(Button, { variant: "ghost", onClick: onClose, children: t("common.cancel") }),
|
|
1758
|
+
/* @__PURE__ */ jsxs(Button, { onClick: () => void save(), disabled: saving, children: [
|
|
1759
|
+
saving && /* @__PURE__ */ jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }),
|
|
1760
|
+
t("common.save")
|
|
1761
|
+
] })
|
|
1762
|
+
] })
|
|
1763
|
+
] }) });
|
|
1764
|
+
}
|
|
1765
|
+
function TemplatesView() {
|
|
1766
|
+
const t = useTranslation();
|
|
1767
|
+
const provider = useNotificationsProvider();
|
|
1768
|
+
const can = usePermissionOptional();
|
|
1769
|
+
const canEdit = can("notifications", "edit");
|
|
1770
|
+
const canDelete = can("notifications", "delete");
|
|
1771
|
+
const [templates2, setTemplates] = React2.useState(null);
|
|
1772
|
+
const [editing, setEditing] = React2.useState(void 0);
|
|
1773
|
+
const [removing, setRemoving] = React2.useState(null);
|
|
1774
|
+
const load = React2.useCallback(async () => {
|
|
1775
|
+
setTemplates(await provider.getTemplates().catch(() => []));
|
|
1776
|
+
}, [provider]);
|
|
1777
|
+
React2.useEffect(() => {
|
|
1778
|
+
void load();
|
|
1779
|
+
}, [load]);
|
|
1780
|
+
const groups = React2.useMemo(() => {
|
|
1781
|
+
const m = /* @__PURE__ */ new Map();
|
|
1782
|
+
for (const tpl of templates2 ?? []) {
|
|
1783
|
+
const list = m.get(tpl.key) ?? [];
|
|
1784
|
+
list.push(tpl);
|
|
1785
|
+
m.set(tpl.key, list);
|
|
1786
|
+
}
|
|
1787
|
+
return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
1788
|
+
}, [templates2]);
|
|
1789
|
+
async function toggleActive(tpl, next) {
|
|
1790
|
+
try {
|
|
1791
|
+
await provider.saveTemplate({
|
|
1792
|
+
id: tpl.id,
|
|
1793
|
+
key: tpl.key,
|
|
1794
|
+
channel: tpl.channel,
|
|
1795
|
+
locale: tpl.locale,
|
|
1796
|
+
purpose: tpl.purpose,
|
|
1797
|
+
subject: tpl.subject,
|
|
1798
|
+
body: tpl.body,
|
|
1799
|
+
variables: tpl.variables,
|
|
1800
|
+
isActive: next
|
|
1801
|
+
});
|
|
1802
|
+
await load();
|
|
1803
|
+
} catch (e) {
|
|
1804
|
+
toast.error(e?.message ?? t("notifications.templates.saveFailed"));
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
|
|
1808
|
+
/* @__PURE__ */ jsx(
|
|
1809
|
+
SubpageHeader,
|
|
1810
|
+
{
|
|
1811
|
+
title: t("notifications.templates.title"),
|
|
1812
|
+
subtitle: t("notifications.templates.subtitle"),
|
|
1813
|
+
actions: /* @__PURE__ */ jsx(PageActions, { actions: [{
|
|
1814
|
+
id: "new-template",
|
|
1815
|
+
label: t("notifications.templates.new"),
|
|
1816
|
+
icon: Plus,
|
|
1817
|
+
intent: "primary",
|
|
1818
|
+
permission: { feature: "notifications", action: "create" },
|
|
1819
|
+
onSelect: () => setEditing(null)
|
|
1820
|
+
}] })
|
|
1821
|
+
}
|
|
1822
|
+
),
|
|
1823
|
+
templates2 === null ? /* @__PURE__ */ jsx("div", { className: "rounded-card border bg-card px-6 py-10 text-center text-sm text-muted-foreground", children: t("common.loading") }) : groups.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "rounded-card border border-dashed bg-card px-6 py-10 text-center", children: [
|
|
1824
|
+
/* @__PURE__ */ jsx(MessageSquare, { className: "mx-auto mb-3 h-8 w-8 text-muted-foreground/50" }),
|
|
1825
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: t("notifications.templates.emptyTitle") }),
|
|
1826
|
+
/* @__PURE__ */ jsx("p", { className: "mx-auto mt-1 max-w-md text-xs text-muted-foreground", children: t("notifications.templates.emptyHint") }),
|
|
1827
|
+
/* @__PURE__ */ jsxs(Button, { className: "mt-4", size: "sm", onClick: () => setEditing(null), children: [
|
|
1828
|
+
/* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5" }),
|
|
1829
|
+
" ",
|
|
1830
|
+
t("notifications.templates.new")
|
|
1831
|
+
] })
|
|
1832
|
+
] }) : /* @__PURE__ */ jsx("div", { className: "space-y-3", "data-testid": "template-list", children: groups.map(([key, list]) => /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card p-4 shadow-sm", "data-testid": "template-group", children: [
|
|
1833
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
1834
|
+
/* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold", children: key }),
|
|
1835
|
+
/* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground", children: t(`notifications.purpose.${list[0].purpose}`) })
|
|
1836
|
+
] }),
|
|
1837
|
+
/* @__PURE__ */ jsx("div", { className: "mt-3 space-y-2", children: list.map((tpl) => /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-3", "data-testid": "template-row", children: [
|
|
1838
|
+
/* @__PURE__ */ jsx("span", { className: "w-24 shrink-0 text-xs font-medium", children: t(`notifications.channel.${tpl.channel}`) }),
|
|
1839
|
+
/* @__PURE__ */ jsxs("p", { className: "min-w-0 flex-1 truncate text-xs text-muted-foreground", children: [
|
|
1840
|
+
tpl.subject ? `${tpl.subject} \u2014 ` : "",
|
|
1841
|
+
tpl.body
|
|
1842
|
+
] }),
|
|
1843
|
+
/* @__PURE__ */ jsx(
|
|
1844
|
+
Switch,
|
|
1845
|
+
{
|
|
1846
|
+
checked: tpl.isActive,
|
|
1847
|
+
disabled: !canEdit,
|
|
1848
|
+
onCheckedChange: (v) => void toggleActive(tpl, v)
|
|
1849
|
+
}
|
|
1850
|
+
),
|
|
1851
|
+
/* @__PURE__ */ jsx(
|
|
1852
|
+
Button,
|
|
1853
|
+
{
|
|
1854
|
+
variant: "ghost",
|
|
1855
|
+
size: "icon",
|
|
1856
|
+
className: "h-8 w-8",
|
|
1857
|
+
disabled: !canEdit,
|
|
1858
|
+
onClick: () => setEditing(tpl),
|
|
1859
|
+
children: /* @__PURE__ */ jsx(Pencil, { className: "h-3.5 w-3.5" })
|
|
1860
|
+
}
|
|
1861
|
+
),
|
|
1862
|
+
/* @__PURE__ */ jsx(
|
|
1863
|
+
Button,
|
|
1864
|
+
{
|
|
1865
|
+
variant: "ghost",
|
|
1866
|
+
size: "icon",
|
|
1867
|
+
className: "h-8 w-8",
|
|
1868
|
+
disabled: !canDelete,
|
|
1869
|
+
onClick: () => setRemoving(tpl),
|
|
1870
|
+
children: /* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5" })
|
|
1871
|
+
}
|
|
1872
|
+
)
|
|
1873
|
+
] }, tpl.id)) })
|
|
1874
|
+
] }, key)) }),
|
|
1875
|
+
/* @__PURE__ */ jsx(
|
|
1876
|
+
TemplateEditorDialog,
|
|
1877
|
+
{
|
|
1878
|
+
open: editing !== void 0,
|
|
1879
|
+
template: editing ?? null,
|
|
1880
|
+
provider,
|
|
1881
|
+
onClose: () => setEditing(void 0),
|
|
1882
|
+
onSaved: () => void load()
|
|
1883
|
+
}
|
|
1884
|
+
),
|
|
1885
|
+
/* @__PURE__ */ jsx(
|
|
1886
|
+
ConfirmDialog,
|
|
1887
|
+
{
|
|
1888
|
+
open: removing !== null,
|
|
1889
|
+
onCancel: () => setRemoving(null),
|
|
1890
|
+
title: t("notifications.templates.deleteTitle"),
|
|
1891
|
+
description: t("notifications.templates.deleteDescription", { key: removing?.key ?? "" }),
|
|
1892
|
+
confirmLabel: t("common.delete"),
|
|
1893
|
+
variant: "destructive",
|
|
1894
|
+
onConfirm: () => {
|
|
1895
|
+
const tpl = removing;
|
|
1896
|
+
setRemoving(null);
|
|
1897
|
+
if (tpl) provider.deleteTemplate(tpl.id).then(load).catch((e) => toast.error(e?.message));
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
)
|
|
1901
|
+
] });
|
|
1902
|
+
}
|
|
1903
|
+
var STATUS_TONE = {
|
|
1904
|
+
sent: "text-success",
|
|
1905
|
+
failed: "text-destructive",
|
|
1906
|
+
abandoned: "text-destructive",
|
|
1907
|
+
blocked: "text-warning",
|
|
1908
|
+
pending: "text-muted-foreground",
|
|
1909
|
+
cancelled: "text-muted-foreground"
|
|
1910
|
+
};
|
|
1911
|
+
function Total({ icon: Icon, label, value, tone }) {
|
|
1912
|
+
return /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card p-4 shadow-sm", children: [
|
|
1913
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
1914
|
+
/* @__PURE__ */ jsx(Icon, { className: `h-3.5 w-3.5 ${tone}` }),
|
|
1915
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: label })
|
|
1916
|
+
] }),
|
|
1917
|
+
/* @__PURE__ */ jsx("p", { className: `mt-1 text-2xl font-semibold ${tone}`, children: value })
|
|
1918
|
+
] });
|
|
1919
|
+
}
|
|
1920
|
+
function HistoryView() {
|
|
1921
|
+
const t = useTranslation();
|
|
1922
|
+
const provider = useNotificationsProvider();
|
|
1923
|
+
const [rows, setRows] = React2.useState(null);
|
|
1924
|
+
const [summary, setSummary] = React2.useState(null);
|
|
1925
|
+
const [channel, setChannel] = React2.useState("all");
|
|
1926
|
+
const [status, setStatus] = React2.useState("all");
|
|
1927
|
+
React2.useEffect(() => {
|
|
1928
|
+
let alive = true;
|
|
1929
|
+
const q = { channel: channel === "all" ? void 0 : channel, status: status === "all" ? void 0 : status, days: 30 };
|
|
1930
|
+
provider.getHistory(q).then((r) => {
|
|
1931
|
+
if (alive) setRows(r);
|
|
1932
|
+
}).catch(() => {
|
|
1933
|
+
if (alive) setRows([]);
|
|
1934
|
+
});
|
|
1935
|
+
provider.getHistorySummary(30).then((s) => {
|
|
1936
|
+
if (alive) setSummary(s);
|
|
1937
|
+
}).catch(() => {
|
|
1938
|
+
if (alive) setSummary(null);
|
|
1939
|
+
});
|
|
1940
|
+
return () => {
|
|
1941
|
+
alive = false;
|
|
1942
|
+
};
|
|
1943
|
+
}, [provider, channel, status]);
|
|
1944
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
|
|
1945
|
+
/* @__PURE__ */ jsx(SubpageHeader, { title: t("notifications.history.title"), subtitle: t("notifications.history.subtitle") }),
|
|
1946
|
+
/* @__PURE__ */ jsxs("div", { className: "grid gap-3 sm:grid-cols-4", "data-testid": "history-totals", children: [
|
|
1947
|
+
/* @__PURE__ */ jsx(Total, { icon: Clock, label: t("notifications.history.total"), value: summary?.total ?? 0, tone: "text-foreground" }),
|
|
1948
|
+
/* @__PURE__ */ jsx(Total, { icon: CheckCircle2, label: t("notifications.history.sent"), value: summary?.sent ?? 0, tone: "text-success" }),
|
|
1949
|
+
/* @__PURE__ */ jsx(Total, { icon: AlertTriangle, label: t("notifications.history.failed"), value: summary?.failed ?? 0, tone: "text-destructive" }),
|
|
1950
|
+
/* @__PURE__ */ jsx(Total, { icon: Ban, label: t("notifications.history.blocked"), value: summary?.blocked ?? 0, tone: "text-warning" })
|
|
1951
|
+
] }),
|
|
1952
|
+
summary?.topReason && summary.topReason.count > 1 && /* @__PURE__ */ jsx("p", { className: "rounded-lg bg-warning/5 px-3 py-2 text-xs text-foreground", "data-testid": "top-reason", children: t("notifications.history.topReason", {
|
|
1953
|
+
count: String(summary.topReason.count),
|
|
1954
|
+
reason: summary.topReason.reason
|
|
1955
|
+
}) }),
|
|
1956
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
|
|
1957
|
+
/* @__PURE__ */ jsxs(Select, { value: channel, onValueChange: (v) => setChannel(v), children: [
|
|
1958
|
+
/* @__PURE__ */ jsx(SelectTrigger, { className: "h-8 w-[170px] text-xs", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
|
|
1959
|
+
/* @__PURE__ */ jsxs(SelectContent, { children: [
|
|
1960
|
+
/* @__PURE__ */ jsx(SelectItem, { value: "all", children: t("notifications.history.allChannels") }),
|
|
1961
|
+
MESSAGE_CHANNELS.map((c) => /* @__PURE__ */ jsx(SelectItem, { value: c, children: t(`notifications.channel.${c}`) }, c))
|
|
1962
|
+
] })
|
|
1963
|
+
] }),
|
|
1964
|
+
/* @__PURE__ */ jsxs(Select, { value: status, onValueChange: (v) => setStatus(v), children: [
|
|
1965
|
+
/* @__PURE__ */ jsx(SelectTrigger, { className: "h-8 w-[170px] text-xs", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
|
|
1966
|
+
/* @__PURE__ */ jsxs(SelectContent, { children: [
|
|
1967
|
+
/* @__PURE__ */ jsx(SelectItem, { value: "all", children: t("notifications.history.allStatuses") }),
|
|
1968
|
+
["sent", "failed", "blocked", "pending", "abandoned"].map((s) => /* @__PURE__ */ jsx(SelectItem, { value: s, children: t(`notifications.status.${s}`) }, s))
|
|
1969
|
+
] })
|
|
1970
|
+
] })
|
|
1971
|
+
] }),
|
|
1972
|
+
/* @__PURE__ */ jsx("div", { className: "overflow-x-auto rounded-card border bg-card", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-sm", children: [
|
|
1973
|
+
/* @__PURE__ */ jsx("thead", { className: "border-b bg-muted/30 text-left text-xs text-muted-foreground", children: /* @__PURE__ */ jsxs("tr", { children: [
|
|
1974
|
+
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: t("notifications.history.when") }),
|
|
1975
|
+
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: t("notifications.history.template") }),
|
|
1976
|
+
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: t("notifications.history.who") }),
|
|
1977
|
+
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: t("notifications.history.channel") }),
|
|
1978
|
+
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: t("notifications.history.status") }),
|
|
1979
|
+
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: t("notifications.history.reason") })
|
|
1980
|
+
] }) }),
|
|
1981
|
+
/* @__PURE__ */ jsx("tbody", { "data-testid": "history-rows", children: rows === null ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan: 6, className: "px-3 py-8 text-center text-xs text-muted-foreground", children: t("common.loading") }) }) : rows.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan: 6, className: "px-3 py-8 text-center text-xs text-muted-foreground", children: t("notifications.history.empty") }) }) : rows.map((r) => /* @__PURE__ */ jsxs("tr", { className: "border-b last:border-b-0", children: [
|
|
1982
|
+
/* @__PURE__ */ jsx("td", { className: "whitespace-nowrap px-3 py-2 text-xs text-muted-foreground", children: new Date(r.createdAt).toLocaleString("pt-BR") }),
|
|
1983
|
+
/* @__PURE__ */ jsx("td", { className: "px-3 py-2 text-xs", children: r.templateKey }),
|
|
1984
|
+
/* @__PURE__ */ jsxs("td", { className: "px-3 py-2", children: [
|
|
1985
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs", children: r.personName ?? "\u2014" }),
|
|
1986
|
+
r.personPhone && /* @__PURE__ */ jsx("span", { className: "ml-2 text-[10px] text-muted-foreground", children: r.personPhone })
|
|
1987
|
+
] }),
|
|
1988
|
+
/* @__PURE__ */ jsx("td", { className: "px-3 py-2 text-xs", children: t(`notifications.channel.${r.channel}`) }),
|
|
1989
|
+
/* @__PURE__ */ jsxs("td", { className: `px-3 py-2 text-xs font-medium ${STATUS_TONE[r.status] ?? ""}`, children: [
|
|
1990
|
+
t(`notifications.status.${r.status}`),
|
|
1991
|
+
r.attempts > 1 && /* @__PURE__ */ jsx("span", { className: "ml-1 text-[10px] text-muted-foreground", children: t("notifications.history.attempts", { n: String(r.attempts) }) })
|
|
1992
|
+
] }),
|
|
1993
|
+
/* @__PURE__ */ jsx("td", { className: "px-3 py-2 text-xs text-muted-foreground", children: r.reason ?? "\u2014" })
|
|
1994
|
+
] }, r.id)) })
|
|
1995
|
+
] }) })
|
|
1996
|
+
] });
|
|
1997
|
+
}
|
|
1998
|
+
function NotificationsPage({ provider }) {
|
|
1999
|
+
const t = useTranslation();
|
|
2000
|
+
const { view, direction, navigate } = useModuleNavigation("/communication", {
|
|
2001
|
+
templates: 0,
|
|
2002
|
+
history: 0
|
|
2003
|
+
}, "templates");
|
|
2004
|
+
const nav = [
|
|
2005
|
+
{
|
|
2006
|
+
id: "templates",
|
|
2007
|
+
label: t("notifications.templates.title"),
|
|
2008
|
+
icon: "MessageSquare",
|
|
2009
|
+
active: view === "templates",
|
|
2010
|
+
onClick: () => navigate("templates")
|
|
2011
|
+
},
|
|
2012
|
+
{
|
|
2013
|
+
id: "history",
|
|
2014
|
+
label: t("notifications.history.title"),
|
|
2015
|
+
icon: "History",
|
|
2016
|
+
active: view === "history",
|
|
2017
|
+
onClick: () => navigate("history")
|
|
2018
|
+
}
|
|
2019
|
+
];
|
|
2020
|
+
const renderView = createViewRouter([
|
|
2021
|
+
{ id: "templates", render: () => /* @__PURE__ */ jsx(TemplatesView, {}) },
|
|
2022
|
+
{ id: "history", render: () => /* @__PURE__ */ jsx(HistoryView, {}) }
|
|
2023
|
+
], "templates");
|
|
2024
|
+
return /* @__PURE__ */ jsx(NotificationsContextProvider, { provider, children: /* @__PURE__ */ jsx(
|
|
2025
|
+
ModulePage,
|
|
2026
|
+
{
|
|
2027
|
+
title: t("notifications.page.title"),
|
|
2028
|
+
subtitle: t("notifications.page.subtitle"),
|
|
2029
|
+
nav,
|
|
2030
|
+
viewKey: view,
|
|
2031
|
+
direction,
|
|
2032
|
+
children: renderView(view)
|
|
2033
|
+
}
|
|
2034
|
+
) });
|
|
2035
|
+
}
|
|
1083
2036
|
|
|
1084
2037
|
// src/index.ts
|
|
1085
2038
|
function createNotificationsPlugin(options) {
|
|
1086
2039
|
registerTranslations(notificationsLocales);
|
|
2040
|
+
const provider = options?.dataProvider ?? createSafeDataProvider(
|
|
2041
|
+
() => createSupabaseNotificationsProvider(),
|
|
2042
|
+
() => createMockNotificationsProvider()
|
|
2043
|
+
);
|
|
2044
|
+
const PageComponent = () => React2.createElement(NotificationsPage, { provider });
|
|
1087
2045
|
return {
|
|
1088
2046
|
id: "notifications",
|
|
1089
2047
|
defaultAgentRole: "operations",
|
|
@@ -1092,14 +2050,25 @@ function createNotificationsPlugin(options) {
|
|
|
1092
2050
|
version: "1.0.0",
|
|
1093
2051
|
scope: options?.scope ?? "universal",
|
|
1094
2052
|
verticalId: options?.verticalId,
|
|
1095
|
-
defaultEnabled: false,
|
|
2053
|
+
defaultEnabled: options?.defaultEnabled ?? false,
|
|
1096
2054
|
dependencies: [],
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
2055
|
+
// A rota é `/communication` e não `/notifications`: o que mora aqui é a
|
|
2056
|
+
// régua com que a casa fala com o cliente, e "notificação" no rail leria
|
|
2057
|
+
// como o sininho do canto da tela.
|
|
2058
|
+
navigation: [
|
|
2059
|
+
{
|
|
2060
|
+
section: "main",
|
|
2061
|
+
position: options?.navPosition ?? 6,
|
|
2062
|
+
label: options?.navLabel ?? "Comunica\xE7\xE3o",
|
|
2063
|
+
route: "/communication",
|
|
2064
|
+
icon: "Send",
|
|
2065
|
+
permission: { feature: "notifications", action: "read" }
|
|
2066
|
+
}
|
|
2067
|
+
],
|
|
2068
|
+
routes: [
|
|
2069
|
+
{ path: "/communication", component: PageComponent, permission: { feature: "notifications", action: "read" } },
|
|
2070
|
+
{ path: "/communication/*", component: PageComponent, permission: { feature: "notifications", action: "read" } }
|
|
2071
|
+
],
|
|
1103
2072
|
// A notification only ANNOUNCES — it writes nothing another module owns — so
|
|
1104
2073
|
// by the phase's ownership rule it reacts to events rather than being called.
|
|
1105
2074
|
subscribesTo: [
|