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:
22
apps/web/public/sw.js
Normal file
22
apps/web/public/sw.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// Service Worker SAR — C6 Web Push
|
||||
// Recebe push events e exibe notificação nativa. Clique abre a URL do payload.
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json() ?? {};
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title ?? 'SAR', {
|
||||
body: data.body ?? '',
|
||||
icon: '/sar-icon.png',
|
||||
badge: '/sar-icon.png',
|
||||
data: data.url ? { url: data.url } : undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url;
|
||||
if (url) {
|
||||
event.waitUntil(clients.openWindow(url));
|
||||
}
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBell, faMagnifyingGlass, faBars } from '@fortawesome/free-solid-svg-icons';
|
||||
import { brandTokens } from '../../lib/theme';
|
||||
import { FoundationStatus } from './FoundationStatus';
|
||||
import { usePendingCount } from '../../lib/queries/notifications';
|
||||
|
||||
interface TopbarProps {
|
||||
onToggleSidebar?: () => void;
|
||||
@@ -14,6 +15,9 @@ interface TopbarProps {
|
||||
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
|
||||
*/
|
||||
export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
const { data: pendingData } = usePendingCount();
|
||||
const pendingCount = pendingData?.count ?? 0;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
@@ -38,11 +42,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
style={{ display: 'inline-flex' }}
|
||||
/>
|
||||
<Flex align="center" gap={12}>
|
||||
<img
|
||||
src="/sar-icon.png"
|
||||
alt="SAR"
|
||||
style={{ height: 40, width: 'auto' }}
|
||||
/>
|
||||
<img src="/sar-icon.png" alt="SAR" style={{ height: 40, width: 'auto' }} />
|
||||
<Flex vertical gap={0}>
|
||||
<span
|
||||
style={{
|
||||
@@ -75,10 +75,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
size="large"
|
||||
placeholder="Buscar cliente, pedido, produto..."
|
||||
prefix={
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
/>
|
||||
<FontAwesomeIcon icon={faMagnifyingGlass} style={{ color: 'var(--text-muted)' }} />
|
||||
}
|
||||
style={{ borderRadius: 12 }}
|
||||
aria-label="Buscar"
|
||||
@@ -88,7 +85,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
{/* Lado direito: status fundação + notificações + perfil */}
|
||||
<Flex align="center" gap={16}>
|
||||
<FoundationStatus />
|
||||
<Badge count={3} color={brandTokens.red} offset={[-4, 4]}>
|
||||
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
|
||||
<Button
|
||||
type="text"
|
||||
size="large"
|
||||
|
||||
42
apps/web/src/lib/hooks/usePushRegistration.ts
Normal file
42
apps/web/src/lib/hooks/usePushRegistration.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
const VAPID_PUBLIC_KEY = import.meta.env['VITE_VAPID_PUBLIC_KEY'] as string | undefined;
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
||||
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(b64);
|
||||
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function usePushRegistration() {
|
||||
useEffect(() => {
|
||||
if (!VAPID_PUBLIC_KEY || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
const sub =
|
||||
existing ??
|
||||
(await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
|
||||
}));
|
||||
const json = sub.toJSON();
|
||||
await apiFetch('/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: json.keys?.['p256dh'] ?? '', auth: json.keys?.['auth'] ?? '' },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Push é opt-in — permissão negada ou SW não disponível é normal
|
||||
}
|
||||
};
|
||||
|
||||
void register();
|
||||
}, []);
|
||||
}
|
||||
15
apps/web/src/lib/queries/notifications.ts
Normal file
15
apps/web/src/lib/queries/notifications.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PendingCountResponseSchema } from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
export function usePendingCount() {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'pending-count'],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch('/notifications/pending-count');
|
||||
return PendingCountResponseSchema.parse(res);
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 20_000,
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,12 @@ import { DevLogin } from './components/dev/DevLogin';
|
||||
|
||||
dayjs.locale('pt-br');
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW é opt-in — falha silenciosa não impede o app
|
||||
});
|
||||
}
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
function Root() {
|
||||
|
||||
Reference in New Issue
Block a user