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:
18
apps/web/src/lib/hooks/useNetworkStatus.ts
Normal file
18
apps/web/src/lib/hooks/useNetworkStatus.ts
Normal 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;
|
||||
}
|
||||
63
apps/web/src/lib/hooks/useOfflineSync.ts
Normal file
63
apps/web/src/lib/hooks/useOfflineSync.ts
Normal 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]);
|
||||
}
|
||||
27
apps/web/src/lib/hooks/usePendingOrders.ts
Normal file
27
apps/web/src/lib/hooks/usePendingOrders.ts
Normal 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 };
|
||||
}
|
||||
54
apps/web/src/lib/offline/idb.ts
Normal file
54
apps/web/src/lib/offline/idb.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
63
apps/web/src/lib/offline/order-queue.ts
Normal file
63
apps/web/src/lib/offline/order-queue.ts
Normal 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'));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
12
apps/web/src/lib/queries/company.ts
Normal file
12
apps/web/src/lib/queries/company.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user