diff --git a/apps/api/src/app/dashboard/dashboard.controller.ts b/apps/api/src/app/dashboard/dashboard.controller.ts index 3a5d380..72d1cef 100644 --- a/apps/api/src/app/dashboard/dashboard.controller.ts +++ b/apps/api/src/app/dashboard/dashboard.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; -import type { RepDashboard } from '@sar/api-interface'; +import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { DashboardService } from './dashboard.service'; @@ -15,4 +15,9 @@ export class DashboardController { repDashboard(): Promise { return this.dashboard.repDashboard(this.cls.get('userId') ?? ''); } + + @Get('supervisor') + supervisorDashboard(): Promise { + return this.dashboard.supervisorDashboard(); + } } diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index c3919d6..ed65c48 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import { OrderStatus } from '@prisma/client'; -import type { RepDashboard } from '@sar/api-interface'; +import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; @Injectable() @@ -106,4 +106,78 @@ export class DashboardService { syncedAt: now.toISOString(), }; } + + async supervisorDashboard(): Promise { + const prisma = this.cls.get('prisma'); + const now = new Date(); + + // Fila de aprovações — mais antigos primeiro + const approvalQueue = await prisma.order.findMany({ + where: { deletedAt: null, status: OrderStatus.pending_approval }, + include: { client: { select: { name: true } } }, + orderBy: { issuedAt: 'asc' }, + take: 50, + }); + + // Pedidos do dia (hoje, meia-noite até agora) + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const todayOrders = await prisma.order.findMany({ + where: { + deletedAt: null, + status: { not: OrderStatus.cancelled }, + issuedAt: { gte: todayStart }, + }, + }); + + // 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.order.findMany({ + where: { + deletedAt: null, + status: { not: OrderStatus.cancelled }, + issuedAt: { gte: lastWeekStart, lte: lastWeekEnd }, + }, + }); + + // Inativos por rep — top 3 reps com mais clientes inativos (>30 dias) + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const inativosPorRep = await prisma.client.groupBy({ + by: ['repId'], + where: { + deletedAt: null, + OR: [{ lastOrderAt: null }, { lastOrderAt: { lt: thirtyDaysAgo } }], + }, + _count: { id: true }, + orderBy: { _count: { id: 'desc' } }, + take: 3, + }); + + const mapOrder = (o: (typeof approvalQueue)[number]) => ({ + id: o.id, + number: o.number, + clientId: o.clientId, + clientName: o.client.name, + repId: o.repId, + status: o.status, + total: String(o.total), + discountPct: String(o.discountPct), + issuedAt: o.issuedAt.toISOString(), + }); + + return { + approvalQueue: approvalQueue.map(mapOrder), + 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), + }, + inativosPorRep: inativosPorRep.map((r) => ({ + repId: r.repId, + inativosCount: r._count.id, + })), + syncedAt: now.toISOString(), + }; + } } diff --git a/apps/web/src/cockpits/sandra/SandraPainel.tsx b/apps/web/src/cockpits/sandra/SandraPainel.tsx new file mode 100644 index 0000000..a2c68d5 --- /dev/null +++ b/apps/web/src/cockpits/sandra/SandraPainel.tsx @@ -0,0 +1,316 @@ +import { Badge, Card, Col, Flex, Row, Skeleton, Space, Table, Tag, Typography } from 'antd'; +import type { TableColumnsType } from 'antd'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faCheckCircle, + faCircleExclamation, + faClipboardList, +} from '@fortawesome/free-solid-svg-icons'; +import { Link } from '@tanstack/react-router'; +import type { OrderSummary } from '@sar/api-interface'; +import { useSupervisorDashboard } from '../../lib/queries/dashboard'; + +const { Title, Text } = Typography; + +function fmt(v: number): string { + return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); +} + +function hoursWaiting(issuedAt: string): number { + return Math.floor((Date.now() - new Date(issuedAt).getTime()) / 3_600_000); +} + +function delta(current: number, previous: number): { label: string; positive: boolean } | null { + if (previous === 0) return null; + const pct = Math.round(((current - previous) / previous) * 100); + return { label: `${pct >= 0 ? '+' : ''}${pct}% vs semana passada`, positive: pct >= 0 }; +} + +function today(): string { + return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); +} + +const queueColumns: TableColumnsType = [ + { + title: 'Pedido', + dataIndex: 'number', + width: 120, + render: (num: string, row: OrderSummary) => ( + + {num} + + ), + }, + { title: 'Rep', dataIndex: 'repId', width: 120, ellipsis: true }, + { title: 'Cliente', dataIndex: 'clientName', ellipsis: true }, + { + title: 'Total', + dataIndex: 'total', + width: 130, + align: 'right', + render: (v: string) => fmt(Number(v)), + }, + { + title: 'Aguardando', + dataIndex: 'issuedAt', + width: 120, + render: (v: string) => { + const h = hoursWaiting(v); + return 2 ? 'red' : 'orange'}>{h}h; + }, + }, + { + title: '', + width: 90, + render: (_: unknown, row: OrderSummary) => ( + + + Analisar + + + ), + }, +]; + +export function SandraPainel() { + const { data, isLoading } = useSupervisorDashboard(); + + if (isLoading || !data) { + return ( + + + + {[1, 2, 3].map((i) => ( + + + + ))} + + + ); + } + + const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data; + const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.issuedAt) > 2).length; + const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior); + const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior); + + return ( + + {/* Saudação */} + + + Bom dia, Sandra + + + {today()} + {urgentCount > 0 && ( + <> + {' '} + ·{' '} + + {urgentCount} aprovação{urgentCount > 1 ? 'ões' : ''} urgente + {urgentCount > 1 ? 's' : ''} + + + )} + + + + {/* KPIs */} + + + + + + APROVAÇÕES PENDENTES + + + + {approvalQueue.length} + + {urgentCount > 0 && ( + + )} + + + + Ver fila completa → + + + + + + + + + + + PEDIDOS HOJE + + + {pedidosDia.count} + + {countDelta && ( + + {countDelta.label} + + )} + + + + + + + + + VALOR HOJE + + + {fmt(pedidosDia.total)} + + {totalDelta && ( + + {totalDelta.label} + + )} + + + + + + {/* Fila de aprovações + Inativos por rep */} + + + + + Fila de Aprovações + {approvalQueue.length > 0 && ( + + )} + + } + extra={Ver todas} + > + + rowKey="id" + columns={queueColumns} + dataSource={approvalQueue.slice(0, 8)} + pagination={false} + size="small" + rowClassName={(row) => (hoursWaiting(row.issuedAt) > 2 ? 'row-urgent' : '')} + locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }} + /> + + + + + + + + Inativos por Rep + + } + extra={ + + sem compra +30 dias + + } + > + {inativosPorRep.length === 0 ? ( + Nenhum inativo no momento. + ) : ( + + {inativosPorRep.map((r) => ( + + + {r.repId} + + = 3 ? 'orange' : 'default'} + className="tabular-nums" + > + {r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''} + + + ))} + + )} + + + + + Pedidos de Hoje + + } + > + + + Total de pedidos + + {pedidosDia.count} + + + + Valor consolidado + + {fmt(pedidosDia.total)} + + + {pedidosDia.countSemanaAnterior > 0 && ( + + Semana passada + + {pedidosDia.countSemanaAnterior} · {fmt(pedidosDia.totalSemanaAnterior)} + + + )} + + + + + + + + SAR · Força de Vendas · Powered by JCS Sistemas + + + Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s + + + + ); +} diff --git a/apps/web/src/lib/queries/dashboard.ts b/apps/web/src/lib/queries/dashboard.ts index 967b8bb..993286b 100644 --- a/apps/web/src/lib/queries/dashboard.ts +++ b/apps/web/src/lib/queries/dashboard.ts @@ -1,5 +1,10 @@ import { useQuery } from '@tanstack/react-query'; -import { RepDashboardSchema, type RepDashboard } from '@sar/api-interface'; +import { + RepDashboardSchema, + SupervisorDashboardSchema, + type RepDashboard, + type SupervisorDashboard, +} from '@sar/api-interface'; import { apiFetch } from '../api-client'; export function useRepDashboard() { @@ -9,6 +14,18 @@ export function useRepDashboard() { const raw = await apiFetch('/dashboard/rep'); return RepDashboardSchema.parse(raw); }, - staleTime: 5 * 60 * 1000, // 5 min + staleTime: 5 * 60 * 1000, + }); +} + +export function useSupervisorDashboard() { + return useQuery({ + queryKey: ['dashboard', 'supervisor'], + queryFn: async () => { + const raw = await apiFetch('/dashboard/supervisor'); + return SupervisorDashboardSchema.parse(raw); + }, + staleTime: 30 * 1000, // 30s — simula near-real-time até C6 (SSE) + refetchInterval: 30 * 1000, }); } diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 85de846..c2f96fc 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -7,6 +7,24 @@ import { OrdersPage } from '../cockpits/rafael/OrdersPage'; import { OrderDetailPage } from '../cockpits/rafael/OrderDetailPage'; import { NewOrderPage } from '../cockpits/rafael/NewOrderPage'; import { ApprovalQueuePage } from '../cockpits/sandra/ApprovalQueuePage'; +import { SandraPainel } from '../cockpits/sandra/SandraPainel'; +import { authStore } from './auth-store'; + +function getRoleFromToken(): string { + const token = authStore.get(); + if (!token) return 'rep'; + try { + const payload = JSON.parse(atob(token.split('.')[1] ?? '')); + return (payload.role as string) ?? 'rep'; + } catch { + return 'rep'; + } +} + +function HomeRoute() { + const role = getRoleFromToken(); + return role === 'supervisor' || role === 'manager' ? : ; +} const rootRoute = createRootRoute({ component: () => ( @@ -19,7 +37,7 @@ const rootRoute = createRootRoute({ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: RafaelPainel, + component: HomeRoute, }); const rafaelRoute = createRoute({ diff --git a/libs/shared/api-interface/src/lib/dashboard.contract.ts b/libs/shared/api-interface/src/lib/dashboard.contract.ts index 2a132bd..d84188d 100644 --- a/libs/shared/api-interface/src/lib/dashboard.contract.ts +++ b/libs/shared/api-interface/src/lib/dashboard.contract.ts @@ -27,3 +27,22 @@ export const RepDashboardSchema = z.object({ syncedAt: z.iso.datetime(), }); export type RepDashboard = z.infer; + +export const RepInativosSummarySchema = z.object({ + repId: z.string(), + inativosCount: z.number().int(), +}); +export type RepInativosSummary = z.infer; + +export const SupervisorDashboardSchema = z.object({ + approvalQueue: z.array(OrderSummarySchema), + pedidosDia: z.object({ + count: z.number().int(), + total: z.number(), + countSemanaAnterior: z.number().int(), + totalSemanaAnterior: z.number(), + }), + inativosPorRep: z.array(RepInativosSummarySchema), + syncedAt: z.iso.datetime(), +}); +export type SupervisorDashboard = z.infer;