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

@@ -7,11 +7,32 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
const SITUA_PENDENTE = 1;
// tipo='G' em gestao.metavenda = meta geral de valor do mês
const TIPO_META_GERAL = 'G';
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
const TIPO_META_GLOBAL = 'GL';
const TIPO_META_GRUPO = 'GR';
interface MetaRow {
// Metas/produtos vivem na MATRIZ; pedidos usam idEmpresa cru (ex.: 9001 → matriz 1).
function matrizEmpresa(idEmpresa: number): number {
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
}
interface MetaErpRow {
tipo: string;
cod_grupo: number | null;
desc_grupo: string | null;
valor: string;
qtdade: string;
peso: string;
vl_fator: string;
}
interface RealizadoGrupoRow {
cod_grupo: number | null;
grupo: string | null;
pedidos: string;
valor: string;
qtd: string;
peso: string;
}
interface RepRow {
@@ -28,6 +49,7 @@ interface InativoRow {
interface InativosPorRepRow {
cod_vendedor: number;
nome_vendedor: string | null;
inativos_count: string;
}
@@ -48,25 +70,33 @@ export class DashboardService {
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
// 1. Meta geral do mês — fonte: gestao.metavenda (via vw_metas), tipo='G'
const metaRows = await prisma.$queryRawUnsafe<MetaRow[]>(`
SELECT valor::text
// 1. Metas do mês — vw_metas vive na matriz (normaliza 9001→1).
// GL = meta global; GR = meta por grupo. A dimensão segue o que o ERP tiver.
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
const metaErpRows = await prisma.$queryRawUnsafe<MetaErpRow[]>(`
SELECT TRIM(tipo) AS tipo, cod_grupo, TRIM(desc_grupo) AS desc_grupo,
valor::text AS valor, qtdade::text AS qtdade,
peso::text AS peso, vl_fator::text AS vl_fator
FROM vw_metas
WHERE id_empresa = ${idEmpresa}
WHERE id_empresa = ${idEmpresaMatriz}
AND cod_vendedor = ${codVendedor}
AND TRIM(tipo) = '${TIPO_META_GERAL}'
AND ano = ${year}
AND mes = ${month}
LIMIT 1
AND ano = ${year}
AND mes = ${month}
AND TRIM(tipo) IN ('${TIPO_META_GLOBAL}', '${TIPO_META_GRUPO}')
`);
const targetAmount = metaRows[0] ? Number(metaRows[0].valor) : 0;
const glRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GLOBAL);
const grRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GRUPO);
// Total global: usa GL se houver; senão soma as metas por grupo (GR).
const targetAmount = glRows.length
? glRows.reduce((a, m) => a + Number(m.valor), 0)
: grRows.reduce((a, m) => a + Number(m.valor), 0);
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
// 2. Taxas do representante — fonte: gestao.vendedor (via vw_representantes)
const repRows = await prisma.$queryRawUnsafe<RepRow[]>(`
SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex
FROM vw_representantes
WHERE id_empresa = ${idEmpresa}
AND codigo = ${codVendedor}
WHERE codigo = ${codVendedor}
LIMIT 1
`);
const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3;
@@ -79,7 +109,8 @@ export class DashboardService {
});
const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1;
// 4. Atingido do mês — pedidos liberados/faturados no ERP (situa 2=Liberado, 4=Faturado)
// 4. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
// Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido.
const monthStartStr = monthStart.toISOString().slice(0, 10);
const monthEndStr = monthEnd.toISOString().slice(0, 10);
@@ -94,7 +125,10 @@ export class DashboardService {
num_ped_sar: string;
numero: number;
id_cliente: number;
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number;
nome_vendedor: string | null;
situa: number;
status_descr: string;
dt_pedido: Date;
@@ -103,13 +137,13 @@ export class DashboardService {
obs: string | null;
}
const [atingidoRows, pedidosMesRows, recentRows] = await Promise.all([
const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COALESCE(SUM(total), 0)::text AS total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa IN (2, 4)
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
`),
@@ -123,16 +157,40 @@ export class DashboardService {
AND dt_pedido <= '${monthEndStr}'
`),
prisma.$queryRawUnsafe<RecentRow[]>(`
SELECT id_pedido, num_ped_sar, numero, id_cliente, cod_vendedor,
situa, status_descr, dt_pedido, total::text, desconto_perc::text, obs
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa != 5
AND dt_pedido >= '${new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}'
ORDER BY dt_pedido DESC
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
c.nome AS nome_cliente, c.razao AS razao_cliente,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = e.cod_vendedor
LIMIT 1) AS nome_vendedor
FROM vw_pedidos_erp e
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
WHERE e.id_empresa = ${idEmpresa}
AND e.cod_vendedor = ${codVendedor}
AND e.situa != 5
AND e.dt_pedido >= '${new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}'
ORDER BY e.dt_pedido DESC
LIMIT 10
`),
// Realizado por grupo — itens faturados (situa 2/4) → produto (matriz) → cod_grupo.
// peso = qtd × peso_líquido do produto; pedidos = nº de pedidos distintos.
prisma.$queryRawUnsafe<RealizadoGrupoRow[]>(`
SELECT p.cod_grupo,
COALESCE(NULLIF(TRIM(p.grupo), ''), p.cod_grupo::text) AS grupo,
COUNT(DISTINCT pi.id_pedido)::text AS pedidos,
COALESCE(SUM(pi.total), 0)::text AS valor,
COALESCE(SUM(pi.qtd), 0)::text AS qtd,
COALESCE(SUM(pi.qtd * COALESCE(p.peso_liquido, 0)), 0)::text AS peso
FROM vw_peditens_erp pi
JOIN vw_pedidos_erp e ON e.id_pedido = pi.id_pedido
JOIN vw_produtos p ON p.id_erp = pi.id_produto AND p.id_empresa = ${idEmpresaMatriz}
WHERE e.id_empresa = ${idEmpresa}
AND e.cod_vendedor = ${codVendedor}
AND e.situa NOT IN (1, 5)
AND e.dt_pedido >= '${monthStartStr}'
AND e.dt_pedido <= '${monthEndStr}'
GROUP BY p.cod_grupo, grupo
`),
]);
const atingido = Number(atingidoRows[0]?.total ?? 0);
@@ -157,10 +215,9 @@ export class DashboardService {
FROM vw_clientes c
LEFT JOIN vw_pedidos_erp p
ON p.id_cliente = c.id_cliente
AND p.id_empresa = c.id_empresa
AND p.id_empresa = ${idEmpresa}
AND p.situa != 5
WHERE c.id_empresa = ${idEmpresa}
AND c.cod_vendedor = ${codVendedor}
WHERE c.cod_vendedor = ${codVendedor}
AND c.ativo = 1
GROUP BY c.id_cliente, c.nome
HAVING MAX(p.dt_pedido) IS NULL
@@ -169,8 +226,41 @@ export class DashboardService {
LIMIT 10
`);
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo.
const realPorGrupo = new Map<number, RealizadoGrupoRow>();
for (const r of realizadoGrupoRows) {
if (r.cod_grupo != null) realPorGrupo.set(Number(r.cod_grupo), r);
}
const metasPorGrupo = grRows
.map((m) => {
const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null;
const real = cod != null ? realPorGrupo.get(cod) : undefined;
const valorMeta = Number(m.valor);
const valorReal = real ? Number(real.valor) : 0;
const pesoMeta = Number(m.peso);
const pesoReal = real ? Number(real.peso) : 0;
return {
codigo: cod,
rotulo: m.desc_grupo || real?.grupo || `Grupo ${cod ?? '?'}`,
pedidos: real ? Number(real.pedidos) : 0,
valorMeta,
valorReal,
qtdMeta: Number(m.qtdade),
qtdReal: real ? Number(real.qtd) : 0,
pesoMeta,
pesoReal,
fatorMeta: Number(m.vl_fator),
fatorReal: pesoReal > 0 ? valorReal / pesoReal : 0,
pct: valorMeta > 0 ? Math.round((valorReal / valorMeta) * 100) : 0,
falta: Math.max(0, valorMeta - valorReal),
};
})
.sort((a, b) => b.valorMeta - a.valorMeta);
return {
meta: { atingido, total: targetAmount, pct, falta },
metaDimensao,
metasPorGrupo,
comissao: { fixa, flex, total: fixa + flex },
pedidosMes,
pedidosRecentes: recentRows.map((o) => ({
@@ -178,7 +268,10 @@ export class DashboardService {
numPedSar: (o.num_ped_sar ?? '').trim(),
numero: Number(o.numero),
idCliente: Number(o.id_cliente),
nomeCliente: o.nome_cliente ?? null,
razaoCliente: o.razao_cliente ?? null,
codVendedor: Number(o.cod_vendedor),
nomeVendedor: o.nome_vendedor ?? null,
situa: Number(o.situa),
statusDescr: o.status_descr,
dtPedido: new Date(o.dt_pedido).toISOString(),
@@ -244,30 +337,58 @@ export class DashboardService {
// Top 3 reps com mais clientes inativos (>30 dias sem compra no ERP)
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
SELECT cod_vendedor, COUNT(*)::text AS inativos_count
SELECT inativos.cod_vendedor,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = inativos.cod_vendedor
LIMIT 1) AS nome_vendedor,
COUNT(*)::text AS inativos_count
FROM (
SELECT c.id_cliente, c.cod_vendedor
FROM vw_clientes c
LEFT JOIN vw_pedidos_erp p
ON p.id_cliente = c.id_cliente
AND p.id_empresa = c.id_empresa
AND p.id_empresa = ${idEmpresa}
AND p.situa != 5
WHERE c.id_empresa = ${idEmpresa}
AND c.ativo = 1
WHERE c.ativo = 1
GROUP BY c.id_cliente, c.cod_vendedor
HAVING MAX(p.dt_pedido) IS NULL
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
) inativos
GROUP BY cod_vendedor
GROUP BY inativos.cod_vendedor
ORDER BY COUNT(*) DESC
LIMIT 3
`);
// Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos)
const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))];
const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))];
const [repNameRows, cliNameRows] = await Promise.all([
repCods.length
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
)
: Promise.resolve([]),
cliIds.length
? prisma.$queryRawUnsafe<
{ id_cliente: number; nome: string | null; razao: string | null }[]
>(
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
)
: Promise.resolve([]),
]);
const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
const cliNameMap = new Map(
cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]),
);
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
id: o.id,
numPedSar: o.numPedSar,
idCliente: o.idCliente,
nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null,
razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null,
codVendedor: o.codVendedor,
nomeVendedor: repNameMap.get(o.codVendedor) ?? null,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
total: String(o.total),
@@ -287,6 +408,7 @@ export class DashboardService {
},
inativosPorRep: inativosPorRep.map((r) => ({
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome_vendedor ?? null,
inativosCount: parseInt(r.inativos_count, 10),
})),
syncedAt: now.toISOString(),