feat(mvp-rep): formas de pagamento do ERP + suporte offline completo

Formas de pagamento:
- Endpoint GET /catalog/payment-methods lendo vw_formas_pagamento
  filtrado por ativa=1 e integrar_sar=1
- FormaPagamento schema/type no shared api-interface
- Hook useFormasPagamento (staleTime 1h) substituindo lista hardcoded

Offline (FR-4.2 / NFR-2.1–2.4):
- IndexedDB queue: lib/offline/idb.ts + order-queue.ts sem deps externos
- NewOrderPage detecta !navigator.onLine → enqueueOrder() → toast + reset
- useOfflineSync: auto-sync ao reconectar (POST orders + PATCH transmit)
- usePendingOrders: fila reativa via CustomEvents
- AppShell: banner offline + useOfflineSync() global
- OrdersPage: seção de pedidos pendentes com retry/descartar
- sw.js: network-first para API GETs cacheáveis + stale-while-revalidate
  para assets + app shell navigate fallback

Docs:
- architecture.md: documento de decisões de arquitetura do SAR MVP

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 21:30:23 +00:00
parent 1647871a39
commit a3c68f9f05
33 changed files with 2175 additions and 173 deletions

View File

@@ -1,5 +1,106 @@
// Service Worker SAR — C6 Web Push
// Recebe push events e exibe notificação nativa. Clique abre a URL do payload.
// Service Worker SAR
// C4/NFR-2: cache de API para uso offline (network-first, fallback to cache)
// C6: Web Push
// App shell: stale-while-revalidate para assets estáticos
const API_CACHE = 'sar-api-v2';
const SHELL_CACHE = 'sar-shell-v2';
// Paths de API que valem ser cacheados para offline
// Auth e mutations (POST/PATCH) nunca são interceptados
const CACHEABLE_API = [
'/api/v1/clients',
'/api/v1/catalog',
'/api/v1/orders',
'/api/v1/dashboard',
'/api/v1/auth/me',
];
// ── Fetch ──────────────────────────────────────────────────────────────────────
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.pathname.startsWith('/api/v1/')) {
const cacheable = CACHEABLE_API.some((p) => url.pathname.startsWith(p));
if (cacheable) {
event.respondWith(networkFirst(request, API_CACHE));
}
return;
}
if (request.mode === 'navigate') {
// App shell HTML — network first, cache fallback
event.respondWith(shellNavigate(request));
return;
}
// Assets estáticos (JS/CSS/fontes/imagens) — stale-while-revalidate
if (/\.(js|css|woff2?|png|svg|ico)$/.test(url.pathname)) {
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
}
});
// Network-first: tenta rede, cai no cache se offline
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
return offlineResponse();
}
}
// Navigate: network first; fallback para a raiz cacheada
async function shellNavigate(request) {
const cache = await caches.open(SHELL_CACHE);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
// Sempre armazena a raiz como fallback universal
cache.put(new Request('/'), response.clone());
}
return response;
} catch {
const cached = (await cache.match(request)) ?? (await cache.match('/'));
if (cached) return cached;
return offlineResponse();
}
}
// Stale-while-revalidate: responde do cache, atualiza em background
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const networkFetch = fetch(request).then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
});
return cached ?? networkFetch;
}
function offlineResponse() {
return new Response(
JSON.stringify({
type: 'sar:offline',
title: 'Sem conexão',
status: 503,
}),
{ status: 503, headers: { 'Content-Type': 'application/json' } },
);
}
// ── Push (C6) ─────────────────────────────────────────────────────────────────
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};

View File

@@ -576,8 +576,15 @@ function CustomerDetailsDrawer({
</Col>
)}
<Col span={12}>
<span style={label}>Cód. Vendedor</span>
<Text style={{ fontSize: 13 }}>{summary.codVendedor}</Text>
<span style={label}>Representante</span>
<Text style={{ fontSize: 13 }}>
{summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`}
{summary.nomeVendedor && (
<Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>
(cód. {summary.codVendedor})
</Text>
)}
</Text>
</Col>
{summary.dtUltimaCompra && (
<Col span={12}>
@@ -787,8 +794,8 @@ function CustomerAnalysisDrawer({
color: urgencyColor,
},
{
label: 'Cód. Vendedor',
value: String(summary.codVendedor),
label: 'Representante',
value: summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`,
icon: <UserOutlined />,
color: '#64748B',
},

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Alert,
App,
AutoComplete,
Button,
Card,
@@ -27,24 +28,20 @@ import {
UserOutlined,
} from '@ant-design/icons';
import { useNavigate, useSearch } from '@tanstack/react-router';
import type { ClientSummary, CreatePedido, Pauta, ProdutoSummary } from '@sar/api-interface';
import type {
ClientSummary,
CreatePedido,
FormaPagamento,
Pauta,
ProdutoSummary,
} from '@sar/api-interface';
import { useClientList, useClientDetail } from '../../lib/queries/clients';
import { useCatalog, usePautas } from '../../lib/queries/catalog';
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
import { apiFetch } from '../../lib/api-client';
import { enqueueOrder } from '../../lib/offline/order-queue';
const { Title, Text } = Typography;
// ─── Condições de pagamento mockadas — substituir por endpoint quando disponível ──
const COND_PAGAMENTO = [
{ value: 1, label: 'À Vista' },
{ value: 2, label: 'Boleto 30 dias' },
{ value: 3, label: 'Boleto 30/60 dias' },
{ value: 4, label: 'Boleto 30/60/90 dias' },
{ value: 5, label: 'Boleto 28/56/84 dias' },
{ value: 6, label: 'PIX' },
{ value: 7, label: 'Cartão de Crédito' },
];
// ─── Tipos internos ────────────────────────────────────────────────────────────
type CartItem = {
@@ -424,6 +421,7 @@ export function NewOrderPage() {
const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams;
const navigate = useNavigate();
const qc = useQueryClient();
const { message } = App.useApp();
// ── Dados do cliente ──
const [clientSearch, setClientSearch] = useState('');
@@ -437,6 +435,7 @@ export function NewOrderPage() {
// ── Campos comerciais ──
const { data: pautas = [] } = usePautas();
const { data: formasPagamento = [] } = useFormasPagamento();
const [idPauta, setIdPauta] = useState<number | undefined>();
const [codFormapag, setCodFormapag] = useState<number | undefined>();
const [contato, setContato] = useState('');
@@ -491,7 +490,6 @@ export function NewOrderPage() {
if (!effectiveClient) throw new Error('Selecione um cliente para continuar.');
if (cart.length === 0) throw new Error('Adicione ao menos um produto ao pedido.');
// Concatena campos extras em obs enquanto não há campos dedicados no backend
const obsCompleta = [
contato ? `Contato: ${contato}` : null,
numOC ? `OC: ${numOC}` : null,
@@ -517,12 +515,35 @@ export function NewOrderPage() {
descontoPerc: it.descontoPerc,
})),
};
return apiFetch('/orders', { method: 'POST', body });
// Offline: enfileira localmente e sincroniza ao reconectar (FR-4.2 / NFR-2.2)
if (!navigator.onLine) {
await enqueueOrder(body, effectiveClient.nome);
window.dispatchEvent(new CustomEvent('sar:order-queued'));
return null; // sinaliza fluxo offline para onSuccess
}
return apiFetch('/orders', { method: 'POST', body }) as Promise<{ id: string }>;
},
onSuccess: () => {
onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['clients'] });
void navigate({ to: '/pedidos' });
if (!created) {
// Offline: pedido enfileirado — mostra confirmação e fica na tela
setError(null);
setCart([]);
setSelectedClient(null);
setClientSearch('');
setCart([]);
setIdPauta(undefined);
setCodFormapag(undefined);
setObs('');
setContato('');
setNumOC('');
message.success('Pedido salvo offline — será transmitido ao reconectar');
return;
}
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
},
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
});
@@ -633,7 +654,10 @@ export function NewOrderPage() {
allowClear
value={codFormapag}
onChange={setCodFormapag}
options={COND_PAGAMENTO}
options={formasPagamento.map((f: FormaPagamento) => ({
value: f.codigo,
label: f.descricao,
}))}
/>
</Col>
<Col xs={24} sm={8}>

View File

@@ -20,8 +20,9 @@ import {
} from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
import { FilePdfOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd';
import { Link, useParams } from '@tanstack/react-router';
import { Link, useParams, useNavigate } from '@tanstack/react-router';
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useOrderDetail } from '../../lib/queries/orders';
@@ -35,6 +36,7 @@ const { TextArea } = Input;
// ─── Helpers ──────────────────────────────────────────────────────────────────
const SITUA_COLOR: Record<number, string> = {
0: 'default',
1: 'warning',
2: 'processing',
3: 'error',
@@ -244,12 +246,14 @@ function RejectModal({
export function OrderDetailPage() {
const { id } = useParams({ from: '/pedidos/$id' });
const navigate = useNavigate();
const qc = useQueryClient();
const { data: order, isLoading, error } = useOrderDetail(id);
const { data: clientOrders } = useClientOrders(order?.idCliente);
const role = getRoleFromToken();
const canAct = role !== 'rep' && order?.situa === 1;
const canTransmit = role === 'rep' && order?.situa === 0;
const canShare =
role === 'rep' &&
(order?.situa === 2 || order?.situa === 4) &&
@@ -288,6 +292,16 @@ export function OrderDetailPage() {
},
});
const transmitMutation = useMutation({
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
// Mensagem de bloqueio de alçada (desconto acima do máximo) vem aqui.
onError: (e: unknown) => setActionError(e instanceof Error ? e.message : 'Erro ao transmitir'),
});
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (error || !order)
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
@@ -297,8 +311,11 @@ export function OrderDetailPage() {
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
: null;
// Orçamento: tela mais larga para consulta/revisão com o cliente.
const isOrcamento = order.situa === 0;
return (
<div style={{ padding: 24, maxWidth: 960 }}>
<div style={{ padding: 24, maxWidth: isOrcamento ? 1320 : 960, margin: '0 auto' }}>
<Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/pedidos"> Pedidos</Link>
<Title level={3} style={{ margin: 0 }}>
@@ -322,6 +339,25 @@ export function OrderDetailPage() {
{timeWaiting !== null && timeWaiting > 2 && (
<Tag color="red">Urgente {timeWaiting}h aguardando</Tag>
)}
<Button
icon={<FilePdfOutlined />}
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
>
Gerar PDF
</Button>
{canTransmit && (
<Button
type="primary"
loading={transmitMutation.isPending}
onClick={() => {
setActionError(null);
transmitMutation.mutate();
}}
style={{ backgroundColor: '#389e0d', borderColor: '#389e0d' }}
>
Transmitir pedido
</Button>
)}
{canAct && (
<Space>
<Button type="primary" onClick={() => setApproveOpen(true)}>
@@ -359,13 +395,20 @@ export function OrderDetailPage() {
/>
)}
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions
bordered
size={isOrcamento ? 'middle' : 'small'}
column={isOrcamento ? 3 : 2}
style={{ marginBottom: 24 }}
>
<Descriptions.Item label="Cliente">
<Link to="/clientes/$id" params={{ id: String(order.idCliente) }}>
Cód. {order.idCliente}
{order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`}
</Link>
</Descriptions.Item>
<Descriptions.Item label="Rep (cód)">{order.codVendedor}</Descriptions.Item>
<Descriptions.Item label="Representante">
{order.nomeVendedor ?? `Cód. ${order.codVendedor}`}
</Descriptions.Item>
<Descriptions.Item label="Data">
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
</Descriptions.Item>
@@ -399,7 +442,7 @@ export function OrderDetailPage() {
columns={itemColumns}
dataSource={order.itens}
pagination={false}
size="small"
size={isOrcamento ? 'middle' : 'small'}
style={{ marginBottom: 24 }}
/>

View File

@@ -0,0 +1,430 @@
import { useEffect } from 'react';
import { Button, Spin, Alert } from 'antd';
import { PrinterOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { useParams, useNavigate } from '@tanstack/react-router';
import { SITUA_LABEL } from '@sar/api-interface';
import { useOrderDetail } from '../../lib/queries/orders';
import { useClientDetail } from '../../lib/queries/clients';
import { useCompany } from '../../lib/queries/company';
// ─── Paleta / tokens ────────────────────────────────────────────────────────
const BLUE = '#003B8E';
const INK = '#1F2937';
const MUTED = '#64748B';
const LINE = '#E5EAF0';
// ─── Helpers de formatação ──────────────────────────────────────────────────
function money(v: string | number | null | undefined): string {
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0);
return (isNaN(n as number) ? 0 : (n as number)).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
});
}
function qty(v: string | number): string {
return Number(v).toLocaleString('pt-BR', { maximumFractionDigits: 3 });
}
function dateBR(v: string | null | undefined): string {
return v ? new Date(v).toLocaleDateString('pt-BR') : '—';
}
function doc(raw: string | null | undefined): string {
if (!raw) return '—';
const d = raw.replace(/\D/g, '');
if (d.length === 14) return d.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '$1.$2.$3/$4-$5');
if (d.length === 11) return d.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
return raw;
}
function phone(raw: string | null | undefined, ddd?: string | null): string {
const d = `${ddd ?? ''}${raw ?? ''}`.replace(/\D/g, '');
if (d.length === 11) return d.replace(/(\d{2})(\d{5})(\d{4})/, '($1) $2-$3');
if (d.length === 10) return d.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2-$3');
return raw ?? '—';
}
function cep(raw: string | null | undefined): string {
const d = (raw ?? '').replace(/\D/g, '');
return d.length === 8 ? d.replace(/(\d{5})(\d{3})/, '$1-$2') : (raw ?? '');
}
// Campos char do ERP vêm com padding — limpa, devolve null se vazio.
function tx(s: string | null | undefined): string | null {
const t = (s ?? '').trim();
return t === '' ? null : t;
}
// ─── Blocos visuais ─────────────────────────────────────────────────────────
const label: React.CSSProperties = {
fontSize: 9,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: MUTED,
display: 'block',
marginBottom: 2,
};
function Field({ k, v }: { k: string; v: React.ReactNode }) {
if (v == null || v === '' || v === '—') return null;
return (
<div style={{ marginBottom: 5 }}>
<span style={label}>{k}</span>
<span style={{ fontSize: 11.5, color: INK }}>{v}</span>
</div>
);
}
export function OrderPrintPage() {
const { id } = useParams({ from: '/pedidos/$id/imprimir' });
const navigate = useNavigate();
const { data: order, isLoading, error } = useOrderDetail(id);
const { data: client } = useClientDetail(order?.idCliente);
const { data: empresa } = useCompany();
// Auto-abre o diálogo de impressão quando tudo carregou.
useEffect(() => {
if (order && empresa) {
const t = setTimeout(() => window.print(), 600);
return () => clearTimeout(t);
}
}, [order, empresa]);
if (isLoading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
if (error || !order)
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
const enderecoCli = client
? [
tx(client.endereco),
tx(client.numEndereco),
tx(client.bairro),
client.cep ? `CEP ${cep(client.cep)}` : null,
]
.filter(Boolean)
.join(', ')
: null;
const cgDigits = (client?.cgcpf ?? '').replace(/\D/g, '').length;
const docLabel = cgDigits === 11 ? 'CPF' : 'CNPJ';
const enderecoEmp = empresa
? [empresa.endereco, empresa.numero, empresa.complemento, empresa.bairro]
.filter(Boolean)
.join(', ')
: null;
const cidadeEmp = empresa
? [empresa.cidade, empresa.uf].filter(Boolean).join(' - ') +
(empresa.cep ? ` · CEP ${cep(empresa.cep)}` : '')
: null;
const clienteNome =
tx(order.razaoCliente) ?? tx(order.nomeCliente) ?? `Cliente ${order.idCliente}`;
const temDesc = Number(order.descontoValor) > 0 || Number(order.descontoPerc) > 0;
return (
<div style={{ background: '#EEF2F7', minHeight: '100vh', padding: '24px 0 60px' }}>
{/* Barra de ações (não imprime) */}
<div
className="no-print"
style={{
maxWidth: 820,
margin: '0 auto 16px',
display: 'flex',
justifyContent: 'space-between',
padding: '0 8px',
}}
>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate({ to: '/pedidos/$id', params: { id } })}
>
Voltar
</Button>
<Button type="primary" icon={<PrinterOutlined />} onClick={() => window.print()}>
Imprimir / Salvar PDF
</Button>
</div>
{/* Documento A4 */}
<div
className="sar-print"
style={{
width: '100%',
maxWidth: 820,
margin: '0 auto',
background: '#fff',
boxShadow: '0 4px 24px rgba(0,0,0,0.10)',
borderRadius: 4,
overflow: 'hidden',
fontFamily: "'Plus Jakarta Sans Variable', system-ui, sans-serif",
color: INK,
}}
>
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
padding: '22px 28px',
borderTop: `5px solid ${BLUE}`,
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)',
borderBottom: `1px solid ${LINE}`,
}}
>
<div style={{ maxWidth: 460 }}>
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}>
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
</div>
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}>
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
{enderecoEmp && <div>{enderecoEmp}</div>}
{cidadeEmp && <div>{cidadeEmp}</div>}
{(empresa?.telefone || empresa?.email) && (
<div>
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
{empresa?.telefone && empresa?.email && <> · </>}
{empresa?.email}
</div>
)}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
PEDIDO
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
{order.numPedSar}
</div>
<div
style={{
display: 'inline-block',
marginTop: 6,
padding: '2px 10px',
borderRadius: 20,
background: `${BLUE}12`,
color: BLUE,
fontSize: 10.5,
fontWeight: 700,
}}
>
{SITUA_LABEL[order.situa] ?? String(order.situa)}
</div>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}>
Emissão: {dateBR(order.dtPedido)}
</div>
</div>
</div>
{/* ── Cliente + Representante ─────────────────────────────────────── */}
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ flex: 1.4, padding: '16px 28px', borderRight: `1px solid ${LINE}` }}>
<div
style={{
fontSize: 10,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.1em',
marginBottom: 10,
}}
>
CLIENTE
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 2 }}>
{clienteNome}
</div>
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && (
<div style={{ fontSize: 11, color: MUTED, marginBottom: 8 }}>{tx(client?.nome)}</div>
)}
<div style={{ marginTop: 8 }}>
<Field k={docLabel} v={doc(client?.cgcpf)} />
<Field k="Inscr. Estadual" v={tx(client?.inscricaoEstadual)} />
<Field k="Endereço" v={enderecoCli} />
<Field k="Telefone" v={phone(client?.telefone, client?.ddd)} />
<Field k="E-mail" v={tx(client?.email)} />
</div>
</div>
<div style={{ flex: 1, padding: '16px 28px' }}>
<div
style={{
fontSize: 10,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.1em',
marginBottom: 10,
}}
>
REPRESENTANTE
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 8 }}>
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
</div>
<Field k="Código" v={String(order.codVendedor)} />
<Field k="Data do pedido" v={dateBR(order.dtPedido)} />
<Field k="Nº do pedido" v={order.numPedSar} />
</div>
</div>
{/* ── Itens ───────────────────────────────────────────────────────── */}
<div style={{ padding: '8px 28px 0' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead>
<tr style={{ background: '#F4F7FB' }}>
{['Cód.', 'Produto', 'Qtd', 'Preço un.', 'Desc.', 'Total'].map((h, i) => (
<th
key={h}
style={{
textAlign: i >= 2 ? 'right' : 'left',
padding: '8px 8px',
color: MUTED,
fontWeight: 700,
fontSize: 9.5,
letterSpacing: '0.05em',
textTransform: 'uppercase',
borderBottom: `2px solid ${LINE}`,
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{order.itens.map((it, idx) => (
<tr key={it.id} style={{ background: idx % 2 ? '#FBFCFE' : '#fff' }}>
<td
style={{ padding: '7px 8px', color: MUTED, borderBottom: `1px solid ${LINE}` }}
>
{it.codProduto ?? '—'}
</td>
<td style={{ padding: '7px 8px', color: INK, borderBottom: `1px solid ${LINE}` }}>
{it.descProduto ?? '—'}
</td>
<td
style={{
padding: '7px 8px',
textAlign: 'right',
borderBottom: `1px solid ${LINE}`,
}}
>
{qty(it.qtd)}
</td>
<td
style={{
padding: '7px 8px',
textAlign: 'right',
borderBottom: `1px solid ${LINE}`,
}}
>
{money(it.precoUnitario)}
</td>
<td
style={{
padding: '7px 8px',
textAlign: 'right',
color: MUTED,
borderBottom: `1px solid ${LINE}`,
}}
>
{Number(it.descontoPerc) > 0 ? `${Number(it.descontoPerc)}%` : '—'}
</td>
<td
style={{
padding: '7px 8px',
textAlign: 'right',
fontWeight: 600,
color: INK,
borderBottom: `1px solid ${LINE}`,
}}
>
{money(it.total)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ── Totais ──────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '14px 28px 4px' }}>
<div style={{ width: 300 }}>
<TotRow k="Total dos produtos" v={money(order.totalProdutos)} />
{Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />}
{Number(order.totalIcmsst) > 0 && <TotRow k="ICMS-ST" v={money(order.totalIcmsst)} />}
{temDesc && (
<TotRow
k={`Desconto${Number(order.descontoPerc) > 0 ? ` (${Number(order.descontoPerc)}%)` : ''}`}
v={`- ${money(order.descontoValor)}`}
/>
)}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
padding: '10px 14px',
background: BLUE,
borderRadius: 6,
color: '#fff',
}}
>
<span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.04em' }}>TOTAL</span>
<span style={{ fontSize: 17, fontWeight: 800 }}>{money(order.total)}</span>
</div>
</div>
</div>
{/* ── Observações + rodapé ────────────────────────────────────────── */}
{order.obs && (
<div style={{ padding: '10px 28px 0' }}>
<span style={label}>Observações</span>
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div>
</div>
)}
<div
style={{
margin: '18px 28px 0',
padding: '12px 0 18px',
borderTop: `1px solid ${LINE}`,
display: 'flex',
justifyContent: 'space-between',
fontSize: 9.5,
color: '#94A3B8',
}}
>
<span>
Documento sem valor fiscal · Pedido de venda emitido pelo representante via SAR.
</span>
<span>SAR · Powered by JCS Sistemas</span>
</div>
</div>
{/* CSS de impressão: esconde tudo menos o documento */}
<style>{`
@media print {
@page { size: A4; margin: 10mm; }
body * { visibility: hidden !important; }
.sar-print, .sar-print * { visibility: visible !important; }
.sar-print { position: absolute; left: 0; top: 0; width: 100% !important;
max-width: none !important; box-shadow: none !important; border-radius: 0 !important; }
.no-print { display: none !important; }
}
`}</style>
</div>
);
}
function TotRow({ k, v }: { k: string; v: string }) {
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
padding: '4px 14px',
fontSize: 11.5,
color: MUTED,
}}
>
<span>{k}</span>
<span style={{ color: INK, fontWeight: 600 }}>{v}</span>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import {
Button,
Card,
Col,
DatePicker,
Drawer,
Dropdown,
Grid,
@@ -19,6 +20,9 @@ import {
} from 'antd';
import type { TableColumnsType } from 'antd';
import type { MenuProps } from 'antd';
import type { Dayjs } from 'dayjs';
const { RangePicker } = DatePicker;
import {
CheckCircleOutlined,
ClockCircleOutlined,
@@ -37,6 +41,8 @@ import { Link, useNavigate } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useOrderList, useOrderDetail } from '../../lib/queries/orders';
import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
@@ -74,8 +80,9 @@ function periodRange(p: string): { from?: string; to?: string } {
// ─── Status Config ────────────────────────────────────────────────────────────
const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = {
0: { label: 'Orçamento', color: '#475569', rowBg: '#f8fafc', tagColor: 'default' },
1: { label: 'Ag. Aprovação', color: '#d46b08', rowBg: '#fffbe6', tagColor: 'orange' },
2: { label: 'Aprovado', color: '#389e0d', rowBg: '#f6ffed', tagColor: 'green' },
2: { label: 'Transmitido', color: '#389e0d', rowBg: '#f6ffed', tagColor: 'green' },
3: { label: 'Cancelado', color: '#cf1322', rowBg: '#fff1f0', tagColor: 'red' },
4: { label: 'Faturado', color: '#1d39c4', rowBg: '#f0f5ff', tagColor: 'geekblue' },
};
@@ -220,7 +227,8 @@ function OrderActionsMenu({
key: 'pdf',
icon: <FilePdfOutlined />,
label: 'Gerar PDF',
onClick: () => alert('PDF em breve'),
disabled: order.fonte === 'erp',
onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }),
},
{
key: 'cancel',
@@ -242,6 +250,7 @@ function OrderActionsMenu({
// ─── OrderDetailDrawer ────────────────────────────────────────────────────────
function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => void }) {
const navigate = useNavigate();
const { data, isLoading } = useOrderDetail(id ?? undefined);
const timelineItems = (data?.historico ?? []).map((h) => ({
@@ -292,7 +301,13 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
<Space>
<Button onClick={onClose}>Fechar</Button>
{data && (
<Button type="primary" onClick={() => alert('PDF em breve')}>
<Button
type="primary"
icon={<FilePdfOutlined />}
onClick={() =>
void navigate({ to: '/pedidos/$id/imprimir', params: { id: data.id } })
}
>
Gerar PDF
</Button>
)}
@@ -333,6 +348,10 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
</Text>
)}
</Col>
<Col span={24}>
<span style={label}>Representante</span>
<Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text>
</Col>
<Col span={12}>
<span style={label}>Total</span>
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
@@ -461,7 +480,11 @@ function MobileOrderCard({
size="small"
icon={<EyeOutlined />}
disabled={order.fonte === 'erp'}
onClick={() => onView(order.id)}
onClick={() =>
order.situa === 0
? void navigate({ to: '/pedidos/$id', params: { id: order.id } })
: onView(order.id)
}
style={{ borderRadius: 6 }}
>
Ver
@@ -488,6 +511,7 @@ export function OrdersPage() {
const screens = useBreakpoint();
const isMobile = !screens.md;
const { message: msg } = App.useApp();
const { orders: pendingOrders, refresh: refreshPending } = usePendingOrders();
const stats = useOrderStats();
@@ -495,11 +519,15 @@ export function OrdersPage() {
const [query, setQuery] = useState('');
const [situaFilter, setSituaFilter] = useState<number | undefined>();
const [period, setPeriod] = useState('');
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
const [page, setPage] = useState(1);
const [drawerOrderId, setDrawerOrderId] = useState<string | null>(null);
const limit = 20;
const { from, to } = period ? periodRange(period) : {};
// Intervalo customizado (datas antigas) tem prioridade sobre o atalho de período.
const periodR = period ? periodRange(period) : {};
const from = range ? range[0].format('YYYY-MM-DD') : periodR.from;
const to = range ? range[1].format('YYYY-MM-DD') : periodR.to;
const { data, isLoading, isFetching } = useOrderList({
numPedSar: query || undefined,
@@ -513,7 +541,7 @@ export function OrdersPage() {
const rows = data?.data ?? [];
const total = data?.total ?? 0;
const hasFilters = !!query || !!situaFilter || !!period;
const hasFilters = !!query || !!situaFilter || !!period || !!range;
function commitSearch() {
setQuery(search.trim());
@@ -525,6 +553,7 @@ export function OrdersPage() {
setQuery('');
setSituaFilter(undefined);
setPeriod('');
setRange(null);
setPage(1);
}
@@ -623,7 +652,11 @@ export function OrdersPage() {
style={{ borderRadius: 6 }}
title="Ver detalhes"
disabled={row.fonte === 'erp'}
onClick={() => setDrawerOrderId(row.id)}
onClick={() =>
row.situa === 0
? void navigate({ to: '/pedidos/$id', params: { id: row.id } })
: setDrawerOrderId(row.id)
}
/>
<OrderActionsMenu order={row} onView={(id) => setDrawerOrderId(id)} />
</Space>
@@ -683,7 +716,7 @@ export function OrdersPage() {
>
<Row gutter={[12, 12]} align="middle">
{/* Busca */}
<Col xs={24} md={8}>
<Col xs={24} md={6}>
<div style={{ position: 'relative' }}>
<SearchOutlined
style={{
@@ -719,7 +752,7 @@ export function OrdersPage() {
</Col>
{/* Status */}
<Col xs={12} sm={8} md={5}>
<Col xs={12} sm={8} md={4}>
<Select
style={{ width: '100%' }}
placeholder="Status"
@@ -730,16 +763,17 @@ export function OrdersPage() {
setPage(1);
}}
options={[
{ value: 0, label: 'Orçamento' },
{ value: 1, label: 'Ag. Aprovação' },
{ value: 2, label: 'Aprovado' },
{ value: 2, label: 'Transmitido' },
{ value: 3, label: 'Cancelado' },
{ value: 4, label: 'Faturado' },
]}
/>
</Col>
{/* Período */}
<Col xs={12} sm={8} md={5}>
{/* Período (atalho) */}
<Col xs={12} sm={8} md={4}>
<Select
style={{ width: '100%' }}
placeholder="Período"
@@ -747,6 +781,7 @@ export function OrdersPage() {
value={period || undefined}
onChange={(v) => {
setPeriod(v ?? '');
if (v) setRange(null); // atalho limpa o intervalo customizado
setPage(1);
}}
options={[
@@ -757,8 +792,28 @@ export function OrdersPage() {
/>
</Col>
{/* Intervalo de datas (para pedidos antigos) */}
<Col xs={24} sm={16} md={6}>
<RangePicker
style={{ width: '100%' }}
value={range}
format="DD/MM/YYYY"
allowClear
placeholder={['Data inicial', 'Data final']}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setRange([dates[0], dates[1]]);
setPeriod(''); // intervalo customizado tem prioridade
} else {
setRange(null);
}
setPage(1);
}}
/>
</Col>
{/* Limpar */}
<Col xs={12} sm={8} md={3}>
<Col xs={12} sm={8} md={4}>
<Button
style={{ width: '100%', borderRadius: 6 }}
icon={<ClearOutlined />}
@@ -778,6 +833,68 @@ export function OrdersPage() {
</Row>
</Card>
{/* ── Pedidos offline pendentes ─────────────────────────────────── */}
{pendingOrders.length > 0 && (
<Card
style={{ borderRadius: 10, border: '1px solid #faad14', marginBottom: 16 }}
styles={{ body: { padding: '12px 16px' } }}
>
<div style={{ fontWeight: 600, marginBottom: 8, color: '#d48806' }}>
{pendingOrders.length === 1
? '1 pedido aguardando sincronização'
: `${pendingOrders.length} pedidos aguardando sincronização`}
</div>
{pendingOrders.map((o) => (
<div
key={o.idempotencyKey}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 0',
borderTop: '1px solid #fde8a4',
fontSize: 13,
}}
>
<span>
<Tag color={o.status === 'failed' ? 'error' : 'warning'}>
{o.status === 'failed' ? 'Falha' : 'Pendente'}
</Tag>
<span style={{ fontWeight: 500 }}>{o.clienteNome}</span>
{o.failReason && (
<span style={{ color: '#cf1322', marginLeft: 8 }}> {o.failReason}</span>
)}
<span style={{ color: '#94a3b8', marginLeft: 8 }}>
{new Date(o.createdAt).toLocaleString('pt-BR')}
</span>
</span>
<Space size={4}>
{o.status === 'failed' && (
<Button
size="small"
onClick={() => {
void retryPendingOrder(o.idempotencyKey).then(refreshPending);
}}
>
Tentar novamente
</Button>
)}
<Button
size="small"
danger
onClick={() => {
void removePendingOrder(o.idempotencyKey).then(refreshPending);
void msg.success('Pedido removido da fila');
}}
>
Descartar
</Button>
</Space>
</div>
))}
</Card>
)}
{/* ── Lista / tabela ────────────────────────────────────────────── */}
{isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}>
@@ -845,7 +962,9 @@ export function OrdersPage() {
scroll={{ x: 900 }}
onRow={(row) => ({
onClick: () => {
if (row.fonte !== 'erp') setDrawerOrderId(row.id);
// Orçamento abre a tela grande de detalhe (com Transmitir); demais, o drawer.
if (row.situa === 0) void navigate({ to: '/pedidos/$id', params: { id: row.id } });
else if (row.fonte !== 'erp') setDrawerOrderId(row.id);
},
style: {
background: STATUS[row.situa]?.rowBg ?? '#fff',

View File

@@ -1,12 +1,14 @@
import { Card, Col, Flex, Progress, Row, Skeleton, Space, Tag, Typography } from 'antd';
import { Card, Col, Flex, Progress, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowTrendUp,
faBullseye,
faCircleExclamation,
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import type { MetaItem, PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
@@ -38,6 +40,97 @@ function today(): string {
});
}
function num(v: number, dec = 0): string {
return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec });
}
// Célula "realizado / meta" — realizado em destaque (verde se bateu), meta abaixo.
function MetaCell({
real,
meta,
money,
dec = 0,
}: {
real: number;
meta: number;
money?: boolean;
dec?: number;
}) {
const f = (v: number) => (money ? fmt(v) : num(v, dec));
const ok = meta > 0 && real >= meta;
return (
<Space orientation="vertical" size={0} style={{ lineHeight: 1.15 }}>
<Text strong className="tabular-nums" style={{ color: ok ? 'var(--green)' : undefined }}>
{f(real)}
</Text>
<Text type="secondary" className="tabular-nums" style={{ fontSize: 'var(--text-xs)' }}>
/ {f(meta)}
</Text>
</Space>
);
}
const metaColumns: TableColumnsType<MetaItem> = [
{
title: 'Grupo',
dataIndex: 'rotulo',
key: 'rotulo',
fixed: 'left',
width: 180,
render: (v: string) => <Text strong>{v}</Text>,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
key: 'pedidos',
align: 'right',
width: 80,
render: (v: number) => <span className="tabular-nums">{num(v)}</span>,
},
{
title: 'Qtde',
key: 'qtd',
align: 'right',
width: 110,
render: (_: unknown, r: MetaItem) => <MetaCell real={r.qtdReal} meta={r.qtdMeta} />,
},
{
title: 'Peso (kg)',
key: 'peso',
align: 'right',
width: 120,
render: (_: unknown, r: MetaItem) => <MetaCell real={r.pesoReal} meta={r.pesoMeta} />,
},
{
title: 'Valor',
key: 'valor',
align: 'right',
width: 160,
render: (_: unknown, r: MetaItem) => <MetaCell real={r.valorReal} meta={r.valorMeta} money />,
},
{
title: 'Fator (R$/kg)',
key: 'fator',
align: 'right',
width: 110,
render: (_: unknown, r: MetaItem) => <MetaCell real={r.fatorReal} meta={r.fatorMeta} dec={2} />,
},
{
title: '% da meta (valor)',
key: 'pct',
align: 'center',
width: 160,
render: (_: unknown, r: MetaItem) => (
<Progress
percent={Math.min(r.pct, 100)}
size="small"
format={() => `${r.pct}%`}
strokeColor={r.pct >= 100 ? 'var(--green)' : 'var(--jcs-blue)'}
/>
),
},
];
export function RepPainel() {
const { data, isLoading } = useRepDashboard();
const { data: user } = useCurrentUser();
@@ -61,7 +154,8 @@ export function RepPainel() {
);
}
const { meta, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } = data;
const { meta, metasPorGrupo, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } =
data;
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
@@ -162,6 +256,82 @@ export function RepPainel() {
</Col>
</Row>
{/* Metas por Grupo — acompanhamento multi-medida do mês */}
{metasPorGrupo.length > 0 && (
<Card
title={
<Space>
<FontAwesomeIcon icon={faBullseye} style={{ color: 'var(--jcs-blue)' }} />
Metas por Grupo
</Space>
}
extra={
<Tag color={meta.pct >= 100 ? 'success' : meta.pct >= 75 ? 'processing' : 'default'}>
{meta.pct}% no valor total
</Tag>
}
>
<Table<MetaItem>
rowKey={(r) => String(r.codigo)}
columns={metaColumns}
dataSource={metasPorGrupo}
size="small"
pagination={false}
scroll={{ x: 820 }}
summary={(rows) => {
const t = rows.reduce(
(a, r) => ({
pedidos: a.pedidos + r.pedidos,
qtdReal: a.qtdReal + r.qtdReal,
qtdMeta: a.qtdMeta + r.qtdMeta,
pesoReal: a.pesoReal + r.pesoReal,
pesoMeta: a.pesoMeta + r.pesoMeta,
valorReal: a.valorReal + r.valorReal,
valorMeta: a.valorMeta + r.valorMeta,
}),
{
pedidos: 0,
qtdReal: 0,
qtdMeta: 0,
pesoReal: 0,
pesoMeta: 0,
valorReal: 0,
valorMeta: 0,
},
);
const pctTotal = t.valorMeta > 0 ? Math.round((t.valorReal / t.valorMeta) * 100) : 0;
const fatorReal = t.pesoReal > 0 ? t.valorReal / t.pesoReal : 0;
const fatorMeta = t.pesoMeta > 0 ? t.valorMeta / t.pesoMeta : 0;
return (
<Table.Summary.Row style={{ background: 'var(--bg-surface-alt)' }}>
<Table.Summary.Cell index={0}>
<Text strong>Total</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<span className="tabular-nums">{num(t.pedidos)}</span>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="right">
<MetaCell real={t.qtdReal} meta={t.qtdMeta} />
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="right">
<MetaCell real={t.pesoReal} meta={t.pesoMeta} />
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="right">
<MetaCell real={t.valorReal} meta={t.valorMeta} money />
</Table.Summary.Cell>
<Table.Summary.Cell index={5} align="right">
<MetaCell real={fatorReal} meta={fatorMeta} dec={2} />
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="center">
<Text strong>{pctTotal}%</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
}}
/>
</Card>
)}
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={12}>
@@ -245,7 +415,7 @@ export function RepPainel() {
</Text>
</Link>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Cód. cliente {o.idCliente}
{o.razaoCliente ?? o.nomeCliente ?? `Cód. cliente ${o.idCliente}`}
</Text>
</Space>
<Flex gap={8} align="center">

View File

@@ -21,8 +21,19 @@ const columns: TableColumnsType<PedidoSummary> = [
</Link>
),
},
{ title: 'Rep (cód)', dataIndex: 'codVendedor', width: 100 },
{ title: 'Cliente (cód)', dataIndex: 'idCliente', width: 110 },
{
title: 'Representante',
key: 'rep',
width: 160,
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Cliente',
key: 'cliente',
width: 200,
render: (_: unknown, row: PedidoSummary) =>
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
},
{
title: 'Total',
dataIndex: 'total',

View File

@@ -49,8 +49,19 @@ const queueColumns: TableColumnsType<PedidoSummary> = [
</Link>
),
},
{ title: 'Rep (cód)', dataIndex: 'codVendedor', width: 100 },
{ title: 'Cliente (cód)', dataIndex: 'idCliente', width: 110 },
{
title: 'Representante',
key: 'rep',
width: 150,
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Cliente',
key: 'cliente',
width: 180,
render: (_: unknown, row: PedidoSummary) =>
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
},
{
title: 'Total',
dataIndex: 'total',
@@ -263,7 +274,12 @@ export function SupervisorPainel() {
}}
>
<Space orientation="vertical" size={0}>
<Text strong>Rep cód. {r.codVendedor}</Text>
<Text strong>{r.nomeVendedor ?? `Rep cód. ${r.codVendedor}`}</Text>
{r.nomeVendedor && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
cód. {r.codVendedor}
</Text>
)}
</Space>
<Tag
color={r.inativosCount >= 3 ? 'orange' : 'default'}

View File

@@ -1,9 +1,11 @@
import { useState, type ReactNode } from 'react';
import { Button, Flex, Tooltip } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { Alert, Button, Flex, Tooltip } from 'antd';
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import { Topbar } from './Topbar';
import { Sidebar } from './Sidebar';
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
interface AppShellProps {
children: ReactNode;
@@ -15,12 +17,23 @@ interface AppShellProps {
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
*/
export function AppShell({ children }: AppShellProps) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_sidebarOpen, setSidebarOpen] = useState(true);
const [, setSidebarOpen] = useState(true);
const navigate = useNavigate();
const isOnline = useNetworkStatus();
useOfflineSync();
return (
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
{!isOnline && (
<Alert
type="warning"
icon={<WifiOutlined />}
showIcon
banner
message="Sem conexão — pedidos lançados ficam salvos e serão enviados ao reconectar"
style={{ padding: '6px 16px', fontSize: 13 }}
/>
)}
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
<Flex flex={1}>
<Sidebar />

View File

@@ -1,4 +1,4 @@
import { Badge, Menu } from 'antd';
import { Menu } from 'antd';
import { useLocation, useNavigate } from '@tanstack/react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
@@ -11,10 +11,8 @@ import {
faGear,
faPercent,
faFileInvoiceDollar,
faCheckCircle,
} from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface';
import { useOrderList } from '../../lib/queries/orders';
/**
* Sidebar canônica do SAR (260px fixa — brand.md).
@@ -23,9 +21,6 @@ import { useOrderList } from '../../lib/queries/orders';
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const { data: pendingOrders } = useOrderList({ status: 'pending_approval', limit: 1 });
const pendingCount = pendingOrders?.total ?? 0;
const items: ItemType[] = [
{
key: '/',
@@ -57,18 +52,6 @@ export function Sidebar() {
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
label: 'Pedidos',
},
{
key: '/aprovacoes',
icon: <FontAwesomeIcon icon={faCheckCircle} fixedWidth />,
label: (
<span>
Aprovações{' '}
{pendingCount > 0 && (
<Badge count={pendingCount} size="small" style={{ marginLeft: 4 }} />
)}
</span>
),
},
{
key: '/comissao',
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
export function useNetworkStatus(): boolean {
const [isOnline, setIsOnline] = useState(() => navigator.onLine);
useEffect(() => {
const up = () => setIsOnline(true);
const down = () => setIsOnline(false);
window.addEventListener('online', up);
window.addEventListener('offline', down);
return () => {
window.removeEventListener('online', up);
window.removeEventListener('offline', down);
};
}, []);
return isOnline;
}

View File

@@ -0,0 +1,63 @@
// Auto-sync da fila offline ao recuperar conexão.
// NFR-2.3: detecta retorno de conexão e sincroniza sem ação do usuário.
// NFR-2.4: falhas visíveis — nunca descarta pedido silenciosamente.
import { useEffect, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
listPendingOrders,
removePendingOrder,
markOrderFailed,
type PendingOrder,
} from '../offline/order-queue';
import { apiFetch } from '../api-client';
export function useOfflineSync() {
const qc = useQueryClient();
const sync = useCallback(async () => {
const pending: PendingOrder[] = await listPendingOrders();
const toSync = pending.filter((o: PendingOrder) => o.status === 'pending');
if (toSync.length === 0) return;
for (const order of toSync as PendingOrder[]) {
try {
const created = (await apiFetch('/orders', {
method: 'POST',
body: order.payload,
})) as { id: string };
// Tenta transmitir — bloqueio duro se acima da alçada; deixa como Orçamento
try {
await apiFetch(`/orders/${created.id}/transmit`, { method: 'PATCH' });
} catch {
// Desconto acima da alçada: pedido fica como Orçamento, rep transmite manualmente
}
await removePendingOrder(order.idempotencyKey);
} catch (e) {
const reason = e instanceof Error ? e.message : 'Erro ao sincronizar pedido';
await markOrderFailed(order.idempotencyKey, reason);
}
}
// Notifica UI para re-render das listas
window.dispatchEvent(new CustomEvent('sar:sync-complete'));
void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['dashboard'] });
}, [qc]);
useEffect(() => {
// Sync imediato no mount se houver fila e conexão
if (navigator.onLine) void sync();
const handleOnline = () => void sync();
const handleRequest = () => void sync();
window.addEventListener('online', handleOnline);
window.addEventListener('sar:sync-request', handleRequest);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('sar:sync-request', handleRequest);
};
}, [sync]);
}

View File

@@ -0,0 +1,27 @@
import { useState, useEffect, useCallback } from 'react';
import { listPendingOrders, type PendingOrder } from '../offline/order-queue';
export function usePendingOrders() {
const [orders, setOrders] = useState<PendingOrder[]>([]);
const refresh = useCallback(async () => {
const pending = await listPendingOrders();
setOrders(pending);
}, []);
useEffect(() => {
void refresh();
const handle = () => void refresh();
window.addEventListener('sar:sync-complete', handle);
window.addEventListener('sar:sync-request', handle);
window.addEventListener('sar:order-queued', handle);
return () => {
window.removeEventListener('sar:sync-complete', handle);
window.removeEventListener('sar:sync-request', handle);
window.removeEventListener('sar:order-queued', handle);
};
}, [refresh]);
return { orders, refresh };
}

View File

@@ -0,0 +1,54 @@
// Wrappers mínimos sobre IndexedDB nativo — sem dependências externas.
// Todos os stores do SAR offline vivem em um único banco versionado.
const DB_NAME = 'sar-offline';
const DB_VERSION = 1;
export const STORE_PENDING_ORDERS = 'pending-orders';
let _db: IDBDatabase | null = null;
function openDB(): Promise<IDBDatabase> {
if (_db) return Promise.resolve(_db);
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_PENDING_ORDERS)) {
db.createObjectStore(STORE_PENDING_ORDERS, { keyPath: 'idempotencyKey' });
}
};
req.onsuccess = () => {
_db = req.result;
resolve(_db);
};
req.onerror = () => reject(req.error);
});
}
export async function idbGetAll<T>(store: string): Promise<T[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(store, 'readonly').objectStore(store).getAll();
req.onsuccess = () => resolve(req.result as T[]);
req.onerror = () => reject(req.error);
});
}
export async function idbPut<T>(store: string, value: T): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(store, 'readwrite').objectStore(store).put(value);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
export async function idbDelete(store: string, key: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const req = db.transaction(store, 'readwrite').objectStore(store).delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}

View File

@@ -0,0 +1,63 @@
// Fila de pedidos pendentes de sync (IndexedDB).
// FR-4.2: lançamento funciona completamente offline.
// FR-4.3: Idempotency-Key gerado localmente antes do envio.
// FR-4.11: falhas de sync nunca descartadas silenciosamente.
import type { CreatePedido } from '@sar/api-interface';
import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb';
export interface PendingOrder {
idempotencyKey: string; // keyPath do IndexedDB
payload: CreatePedido;
clienteNome: string;
status: 'pending' | 'failed';
failReason?: string;
createdAt: string;
}
export function listPendingOrders(): Promise<PendingOrder[]> {
return idbGetAll<PendingOrder>(STORE_PENDING_ORDERS);
}
export async function enqueueOrder(
payload: CreatePedido,
clienteNome: string,
): Promise<PendingOrder> {
const key = payload.idempotencyKey ?? crypto.randomUUID();
const order: PendingOrder = {
idempotencyKey: key,
payload: { ...payload, idempotencyKey: key },
clienteNome,
status: 'pending',
createdAt: new Date().toISOString(),
};
await idbPut<PendingOrder>(STORE_PENDING_ORDERS, order);
return order;
}
export async function removePendingOrder(idempotencyKey: string): Promise<void> {
return idbDelete(STORE_PENDING_ORDERS, idempotencyKey);
}
export async function markOrderFailed(idempotencyKey: string, reason: string): Promise<void> {
const all = await listPendingOrders();
const order = all.find((o) => o.idempotencyKey === idempotencyKey);
if (!order) return;
await idbPut<PendingOrder>(STORE_PENDING_ORDERS, {
...order,
status: 'failed',
failReason: reason,
});
}
export async function retryPendingOrder(idempotencyKey: string): Promise<void> {
const all = await listPendingOrders();
const order = all.find((o) => o.idempotencyKey === idempotencyKey);
if (!order) return;
await idbPut<PendingOrder>(STORE_PENDING_ORDERS, {
...order,
status: 'pending',
failReason: undefined,
});
window.dispatchEvent(new CustomEvent('sar:sync-request'));
}

View File

@@ -1,8 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import {
FormaPagamentoSchema,
PautaSchema,
ProdutoListResponseSchema,
ProdutoDetailSchema,
type FormaPagamento,
type ProdutoListQuery,
type ProdutoListResponse,
type ProdutoDetail,
@@ -22,6 +24,17 @@ export function usePautas() {
});
}
export function useFormasPagamento() {
return useQuery<FormaPagamento[]>({
queryKey: ['catalog', 'payment-methods'],
queryFn: async () => {
const res = await apiFetch('/catalog/payment-methods');
return z.array(FormaPagamentoSchema).parse(res);
},
staleTime: 60 * 60 * 1000,
});
}
export function useCatalog(params: Partial<ProdutoListQuery> = {}) {
const search = new URLSearchParams();
if (params.q) search.set('q', params.q);

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { EmpresaInfoSchema, type EmpresaInfo } from '@sar/api-interface';
import { apiFetch } from '../api-client';
// Dados da empresa matriz (cabeçalho do PDF do pedido). Cache longo — muda raramente.
export function useCompany() {
return useQuery<EmpresaInfo, Error>({
queryKey: ['company'],
queryFn: async () => EmpresaInfoSchema.parse(await apiFetch('/catalog/company')),
staleTime: 1000 * 60 * 30,
});
}

View File

@@ -12,6 +12,7 @@ import { ClientsPage } from '../cockpits/rep/ClientsPage';
import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage';
import { OrdersPage } from '../cockpits/rep/OrdersPage';
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
@@ -98,6 +99,12 @@ const pedidoDetailRoute = createRoute({
component: OrderDetailPage,
});
const pedidoPrintRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos/$id/imprimir',
component: OrderPrintPage,
});
const catalogoRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/catalogo',
@@ -118,6 +125,7 @@ const routeTree = rootRoute.addChildren([
pedidosRoute,
novoOrderRoute,
pedidoDetailRoute,
pedidoPrintRoute,
catalogoRoute,
aprovacoes,
]);