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

@@ -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,
]);