@fayz-ai/plugin-notifications 0.2.0 → 0.2.2

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/index.js CHANGED
@@ -1,4 +1,9 @@
1
- import { registerTranslations } from '@fayz-ai/core';
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
- // No screen yet, and that is the whole shape of this plugin: everything it
1098
- // does is reachable from SQL and from the subscriptions below. An admin
1099
- // surface is a follow-up shipping the engine without one is deliberate,
1100
- // because a form that cannot send is what this phase exists to stop.
1101
- navigation: [],
1102
- routes: [],
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: [