diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 6b3f828..21b5de5 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -3,10 +3,8 @@ import { ClsService } from 'nestjs-cls'; import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; -// Situa SAR: 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado -const SITUA_APROVADO = 2; -const SITUA_FATURADO = 4; -const SITUA_CANCELADO = 3; +// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado +// 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 @@ -81,17 +79,64 @@ export class DashboardService { }); const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1; - // 4. Pedidos aprovados/faturados do mês (base de cálculo) - const approvedThisMonth = await prisma.pedido.findMany({ - where: { - codVendedor, - idEmpresa, - situa: { in: [SITUA_APROVADO, SITUA_FATURADO] }, - dtPedido: { gte: monthStart, lte: monthEnd }, - }, - }); + // 4. Atingido do mês — pedidos liberados/faturados no ERP (situa 2=Liberado, 4=Faturado) + const monthStartStr = monthStart.toISOString().slice(0, 10); + const monthEndStr = monthEnd.toISOString().slice(0, 10); - const atingido = approvedThisMonth.reduce((s, o) => s + Number(o.total), 0); + interface TotalRow { + total: string; + } + interface CountRow { + count: string; + } + interface RecentRow { + id_pedido: number; + num_ped_sar: string; + numero: number; + id_cliente: number; + cod_vendedor: number; + situa: number; + status_descr: string; + dt_pedido: Date; + total: string; + desconto_perc: string; + obs: string | null; + } + + const [atingidoRows, pedidosMesRows, recentRows] = await Promise.all([ + prisma.$queryRawUnsafe(` + 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 dt_pedido >= '${monthStartStr}' + AND dt_pedido <= '${monthEndStr}' + `), + prisma.$queryRawUnsafe(` + SELECT COUNT(*)::text AS count + FROM vw_pedidos_erp + WHERE id_empresa = ${idEmpresa} + AND cod_vendedor = ${codVendedor} + AND situa != 5 + AND dt_pedido >= '${monthStartStr}' + AND dt_pedido <= '${monthEndStr}' + `), + prisma.$queryRawUnsafe(` + 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 + LIMIT 10 + `), + ]); + + const atingido = Number(atingidoRows[0]?.total ?? 0); + const pedidosMes = Number(pedidosMesRows[0]?.count ?? 0); const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0; const falta = Math.max(0, targetAmount - atingido); @@ -101,30 +146,7 @@ export class DashboardService { ? Math.round(atingido * flexRate) / 100 : 0; - // 5. Contagem de pedidos do mês (exceto cancelado) - const pedidosMes = await prisma.pedido.count({ - where: { - codVendedor, - idEmpresa, - situa: { not: SITUA_CANCELADO }, - dtPedido: { gte: monthStart, lte: monthEnd }, - }, - }); - - // 6. Pedidos recentes — últimos 7 dias - const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const recentOrders = await prisma.pedido.findMany({ - where: { - codVendedor, - idEmpresa, - situa: { not: SITUA_CANCELADO }, - dtPedido: { gte: sevenDaysAgo }, - }, - orderBy: { dtPedido: 'desc' }, - take: 10, - }); - - // 7. Clientes inativos — sem pedido há >30 dias (vw_clientes + sar.pedidos) + // 7. Clientes inativos — sem pedido no ERP há >30 dias const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const inactiveClients = await prisma.$queryRawUnsafe(` SELECT @@ -133,16 +155,16 @@ export class DashboardService { MAX(p.dt_pedido) AS dt_ultima_compra, MAX(p.total)::text AS ultima_compra_valor FROM vw_clientes c - LEFT JOIN pedidos p + LEFT JOIN vw_pedidos_erp p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa - AND p.situa != ${SITUA_CANCELADO} + AND p.situa != 5 WHERE c.id_empresa = ${idEmpresa} AND c.cod_vendedor = ${codVendedor} AND c.ativo = 1 GROUP BY c.id_cliente, c.nome HAVING MAX(p.dt_pedido) IS NULL - OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString()}' + OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}' ORDER BY dt_ultima_compra ASC NULLS FIRST LIMIT 10 `); @@ -151,17 +173,20 @@ export class DashboardService { meta: { atingido, total: targetAmount, pct, falta }, comissao: { fixa, flex, total: fixa + flex }, pedidosMes, - pedidosRecentes: recentOrders.map((o) => ({ - id: o.id, - numPedSar: o.numPedSar, - idCliente: o.idCliente, - codVendedor: o.codVendedor, - situa: o.situa, - dtPedido: o.dtPedido.toISOString(), - total: String(o.total), - descontoPerc: String(o.descontoPerc), - obs: o.obs, - createdAt: o.createdAt.toISOString(), + pedidosRecentes: recentRows.map((o) => ({ + id: `erp-${o.id_pedido}`, + numPedSar: (o.num_ped_sar ?? '').trim(), + numero: Number(o.numero), + idCliente: Number(o.id_cliente), + codVendedor: Number(o.cod_vendedor), + situa: Number(o.situa), + statusDescr: o.status_descr, + dtPedido: new Date(o.dt_pedido).toISOString(), + total: o.total ?? '0', + descontoPerc: o.desconto_perc ?? '0', + obs: o.obs ?? null, + createdAt: new Date(o.dt_pedido).toISOString(), + fonte: 'erp' as const, })), clientesInativos: inactiveClients.map((c) => ({ idCliente: Number(c.id_cliente), @@ -181,51 +206,57 @@ export class DashboardService { const idEmpresa = this.cls.get('idEmpresa'); const now = new Date(); - // Fila de aprovações — mais antigos primeiro + // Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP) const approvalQueue = await prisma.pedido.findMany({ where: { idEmpresa, situa: SITUA_PENDENTE }, orderBy: { dtPedido: 'asc' }, take: 50, }); - // Pedidos do dia + // Pedidos do dia — lê do ERP (situa != 5=Cancelado) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const todayOrders = await prisma.pedido.findMany({ - where: { - idEmpresa, - situa: { not: SITUA_CANCELADO }, - dtPedido: { gte: todayStart }, - }, - }); + const todayStr = todayStart.toISOString().slice(0, 10); + const lastWeekStr = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const lastWeekEndStr = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); - // Mesmo dia da semana passada (comparativo) - const lastWeekStart = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000); - const lastWeekEnd = new Date(lastWeekStart.getTime() + 24 * 60 * 60 * 1000 - 1); - const lastWeekOrders = await prisma.pedido.findMany({ - where: { - idEmpresa, - situa: { not: SITUA_CANCELADO }, - dtPedido: { gte: lastWeekStart, lte: lastWeekEnd }, - }, - }); + interface DayRow { + count: string; + total: string; + } + const [todayRows, lastWeekRows] = await Promise.all([ + prisma.$queryRawUnsafe(` + SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total + FROM vw_pedidos_erp + WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}' + `), + prisma.$queryRawUnsafe(` + SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total + FROM vw_pedidos_erp + WHERE id_empresa = ${idEmpresa} AND situa != 5 + AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}' + `), + ]); - // Top 3 reps com mais clientes inativos (>30 dias sem compra) - // Subconsulta agrupa por cliente, outer agrupa por rep + // 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(` SELECT cod_vendedor, COUNT(*)::text AS inativos_count FROM ( SELECT c.id_cliente, c.cod_vendedor FROM vw_clientes c - LEFT JOIN pedidos p + LEFT JOIN vw_pedidos_erp p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa - AND p.situa != ${SITUA_CANCELADO} + AND p.situa != 5 WHERE c.id_empresa = ${idEmpresa} AND 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()}' + OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}' ) inativos GROUP BY cod_vendedor ORDER BY COUNT(*) DESC @@ -243,15 +274,16 @@ export class DashboardService { descontoPerc: String(o.descontoPerc), obs: o.obs, createdAt: o.createdAt.toISOString(), + fonte: 'sar' as const, }); return { approvalQueue: approvalQueue.map(mapPedido), pedidosDia: { - count: todayOrders.length, - total: todayOrders.reduce((s, o) => s + Number(o.total), 0), - countSemanaAnterior: lastWeekOrders.length, - totalSemanaAnterior: lastWeekOrders.reduce((s, o) => s + Number(o.total), 0), + count: Number(todayRows[0]?.count ?? 0), + total: Number(todayRows[0]?.total ?? 0), + countSemanaAnterior: Number(lastWeekRows[0]?.count ?? 0), + totalSemanaAnterior: Number(lastWeekRows[0]?.total ?? 0), }, inativosPorRep: inativosPorRep.map((r) => ({ codVendedor: Number(r.cod_vendedor), diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index 427ec0b..ef03d82 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -38,48 +38,65 @@ export class OrdersService { const codVendedor = userId ? parseInt(userId, 10) : 0; const { idCliente, situa, numPedSar, from, to, page, limit } = query; - const skip = (page - 1) * limit; + const offset = (page - 1) * limit; - const repFilter = role === 'rep' ? { codVendedor } : {}; + const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : ''; + const clienteFilter = idCliente != null ? `AND e.id_cliente = ${idCliente}` : ''; + const situaFilter = situa != null ? `AND e.situa = ${situa}` : ''; + const pedSarFilter = numPedSar ? `AND TRIM(e.num_ped_sar) ILIKE '%${numPedSar}%'` : ''; + const fromFilter = from ? `AND e.dt_pedido >= '${from}'` : ''; + const toFilter = to ? `AND e.dt_pedido <= '${to}'` : ''; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const where: any = { - idEmpresa, - ...repFilter, - ...(idCliente != null ? { idCliente } : {}), - ...(situa != null ? { situa } : {}), - ...(numPedSar ? { numPedSar: { contains: numPedSar } } : {}), - ...(from || to - ? { - dtPedido: { - ...(from ? { gte: new Date(from) } : {}), - ...(to ? { lte: new Date(to) } : {}), - }, - } - : {}), - }; + const filters = ` + WHERE e.id_empresa = ${idEmpresa} + ${vendedorFilter} ${clienteFilter} ${situaFilter} + ${pedSarFilter} ${fromFilter} ${toFilter} + `; - const [rows, total] = await Promise.all([ - prisma.pedido.findMany({ - where, - skip, - take: limit, - orderBy: { dtPedido: 'desc' }, - }), - prisma.pedido.count({ where }), + interface ErpRow { + id_pedido: number; + num_ped_sar: string; + numero: number; + id_cliente: number; + cod_vendedor: number; + situa: number; + status_descr: string; + dt_pedido: Date; + total: string; + desconto_perc: string; + obs: string | null; + } + + const [rows, countRows] = await Promise.all([ + prisma.$queryRawUnsafe(` + 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 + FROM vw_pedidos_erp e + ${filters} + ORDER BY e.dt_pedido DESC + LIMIT ${limit} OFFSET ${offset} + `), + prisma.$queryRawUnsafe<[{ count: string }]>(` + SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters} + `), ]); + const total = Number(countRows[0]?.count ?? 0); + const data: PedidoSummary[] = rows.map((o) => ({ - id: o.id, - numPedSar: o.numPedSar, - idCliente: o.idCliente, - codVendedor: o.codVendedor, - situa: o.situa, - dtPedido: o.dtPedido.toISOString(), - total: decimalToString(o.total), - descontoPerc: decimalToString(o.descontoPerc), - obs: o.obs, - createdAt: o.createdAt.toISOString(), + id: `erp-${o.id_pedido}`, + numPedSar: (o.num_ped_sar ?? '').trim(), + numero: Number(o.numero), + idCliente: Number(o.id_cliente), + codVendedor: Number(o.cod_vendedor), + situa: Number(o.situa), + statusDescr: o.status_descr, + dtPedido: new Date(o.dt_pedido).toISOString(), + total: o.total ?? '0', + descontoPerc: o.desconto_perc ?? '0', + obs: o.obs ?? null, + createdAt: new Date(o.dt_pedido).toISOString(), + fonte: 'erp', })); return { data, total, page, limit }; @@ -388,6 +405,7 @@ export class OrdersService { acrescimo: decimalToString(o.acrescimo), comissao: decimalToString(o.comissao), pedFlex: decimalToString(o.pedFlex), + fonte: 'sar' as const, aprovadoPor: o.aprovadoPor, aprovadoEm: o.aprovadoEm?.toISOString() ?? null, motivoRecusa: o.motivoRecusa, diff --git a/apps/web/src/cockpits/rafael/OrdersPage.tsx b/apps/web/src/cockpits/rafael/OrdersPage.tsx index ea73f49..b4d747e 100644 --- a/apps/web/src/cockpits/rafael/OrdersPage.tsx +++ b/apps/web/src/cockpits/rafael/OrdersPage.tsx @@ -19,31 +19,39 @@ const SITUA_COLOR: Record = { const columns: TableColumnsType = [ { title: 'Nº', - dataIndex: 'numPedSar', + dataIndex: 'numero', width: 120, - render: (num: string, row: PedidoSummary) => ( - - {num} - - ), + render: (_: number, row: PedidoSummary) => { + const label = row.numero ? String(row.numero) : row.numPedSar || row.id; + return row.fonte === 'erp' ? ( + {label} + ) : ( + + {label} + + ); + }, }, { title: 'Status', dataIndex: 'situa', width: 150, - render: (s: number) => ( - {SITUA_LABEL[s] ?? String(s)}} - /> - ), + render: (s: number, row: PedidoSummary) => { + const label = row.statusDescr ?? SITUA_LABEL[s] ?? String(s); + return ( + {label}} + /> + ); + }, }, { title: 'Total', diff --git a/apps/web/src/components/dev/DevLogin.tsx b/apps/web/src/components/dev/DevLogin.tsx index fd8d8a1..f3d3dca 100644 --- a/apps/web/src/components/dev/DevLogin.tsx +++ b/apps/web/src/components/dev/DevLogin.tsx @@ -10,11 +10,11 @@ import { AuthTokenResponseSchema } from '@sar/api-interface'; type DevUser = { userId: string; role: string; label: string }; // userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1) +// Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado. const DEV_USERS: DevUser[] = [ - { userId: '101', role: 'rep', label: 'Rafael — Rep (cod 101)' }, - { userId: '102', role: 'rep', label: 'Rep 2 (cod 102)' }, - { userId: '201', role: 'supervisor', label: 'Sandra — Supervisora (cod 201)' }, - { userId: '301', role: 'manager', label: 'Gerente (cod 301)' }, + { userId: '29', role: 'rep', label: 'PAVEI COMERCIO (cod 29)' }, + { userId: '29', role: 'supervisor', label: 'PAVEI — Supervisor (cod 29)' }, + { userId: '29', role: 'manager', label: 'PAVEI — Gerente (cod 29)' }, ]; export function DevLogin({ onLogin }: { onLogin: () => void }) { diff --git a/apps/web/src/lib/queries/orders.ts b/apps/web/src/lib/queries/orders.ts index b8527ba..22cace7 100644 --- a/apps/web/src/lib/queries/orders.ts +++ b/apps/web/src/lib/queries/orders.ts @@ -24,8 +24,7 @@ export function useOrderList(params: Partial = {}) { queryKey: ['orders', params], queryFn: async () => { const res = await apiFetch(`/orders${qs ? `?${qs}` : ''}`); - if (!res.ok) throw new Error(`orders list error ${res.status}`); - return PedidoListResponseSchema.parse(await res.json()); + return PedidoListResponseSchema.parse(res); }, }); } @@ -36,8 +35,7 @@ export function useOrderDetail(id: string | undefined) { enabled: !!id, queryFn: async () => { const res = await apiFetch(`/orders/${id}`); - if (!res.ok) throw new Error(`order detail error ${res.status}`); - return PedidoDetailSchema.parse(await res.json()); + return PedidoDetailSchema.parse(res); }, }); } @@ -48,8 +46,7 @@ export function useClientOrders(idCliente: number | undefined) { enabled: idCliente != null, queryFn: async () => { const res = await apiFetch(`/orders?idCliente=${idCliente}&limit=10`); - if (!res.ok) throw new Error(`client orders error ${res.status}`); - const data = PedidoListResponseSchema.parse(await res.json()); + const data = PedidoListResponseSchema.parse(res); return data.data; }, }); diff --git a/libs/shared/api-interface/src/lib/order.contract.ts b/libs/shared/api-interface/src/lib/order.contract.ts index b44bdae..97b0c72 100644 --- a/libs/shared/api-interface/src/lib/order.contract.ts +++ b/libs/shared/api-interface/src/lib/order.contract.ts @@ -47,16 +47,19 @@ export type HistoricoPedido = z.infer; // ─── Pedido Summary (lista) ─────────────────────────────────────────────────── export const PedidoSummarySchema = z.object({ - id: z.string().uuid(), + id: z.string(), // UUID para pedidos SAR, 'erp-{id}' para histórico ERP numPedSar: z.string(), + numero: z.number().int().optional(), // número do pedido no ERP idCliente: z.number().int(), codVendedor: z.number().int(), situa: z.number().int(), + statusDescr: z.string().optional(), // descrição legível do status dtPedido: z.string(), total: z.string(), descontoPerc: z.string(), obs: z.string().nullable(), createdAt: z.iso.datetime(), + fonte: z.enum(['sar', 'erp']).default('sar'), }); export type PedidoSummary = z.infer;