feat(c6): notificações e push — Web Push VAPID, badge dinâmico, Share API

FR-6.1/6.2: Sandra recebe push quando pedido entra em pending_approval;
Rafael recebe quando pedido é aprovado ou recusado. Service worker registrado
em background (PWA-ready via public/sw.js).

FR-6.3: Badge na Topbar busca GET /notifications/pending-count (supervisores
veem count de pending_approval; reps veem 0). Intervalo de 30s.

FR-6.4: Botão Compartilhar no OrderDetailPage para pedidos approved/invoiced
(apenas reps). Usa navigator.share() com texto formatado para WhatsApp.

Infra: modelo PushSubscription (Prisma), NotificationsModule (subscribe/
unsubscribe/pending-count + PushService VAPID), VAPID keys em .env,
integração no OrdersService (create → supervisores, approve/reject → repId).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 12:31:13 +00:00
parent e3587e680a
commit a1a852c44d
22 changed files with 522 additions and 18 deletions

View File

@@ -16,7 +16,10 @@ import {
Timeline,
Typography,
Input,
message,
} from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
import type { TableColumnsType } from 'antd';
import { Link, useParams } from '@tanstack/react-router';
import type { OrderItem, OrderStatus, OrderStatusHistory } from '@sar/api-interface';
@@ -49,6 +52,25 @@ function fmt(v: string | number): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function buildShareText(order: {
number: string;
clientName: string;
total: string;
items: Array<{ productName: string; quantity: string; unitPrice: string }>;
}): string {
const lines = [
`*Pedido ${order.number}${order.clientName}*`,
'',
...order.items.map(
(it) =>
`${it.productName} × ${Number(it.quantity).toLocaleString('pt-BR')}${fmt(it.unitPrice)} un.`,
),
'',
`*Total: ${fmt(order.total)}*`,
];
return lines.join('\n');
}
function getRoleFromToken(): string {
const token = authStore.get();
if (!token) return 'rep';
@@ -230,6 +252,11 @@ export function OrderDetailPage() {
const role = getRoleFromToken();
const canAct = role !== 'rep' && order?.status === 'pending_approval';
const canShare =
role === 'rep' &&
(order?.status === 'approved' || order?.status === 'invoiced') &&
typeof navigator !== 'undefined' &&
!!navigator.share;
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
@@ -298,6 +325,20 @@ export function OrderDetailPage() {
</Button>
</Space>
)}
{canShare && (
<Button
icon={<FontAwesomeIcon icon={faShareNodes} />}
onClick={async () => {
try {
await navigator.share({ text: buildShareText(order) });
} catch {
void message.info('Compartilhamento cancelado');
}
}}
>
Compartilhar
</Button>
)}
</Space>
{actionError && (