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

@@ -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 }}
/>