diff --git a/apps/api/prisma/migrations/20260722140000_config_empresa_meta_positivacao/migration.sql b/apps/api/prisma/migrations/20260722140000_config_empresa_meta_positivacao/migration.sql new file mode 100644 index 0000000..8df728a --- /dev/null +++ b/apps/api/prisma/migrations/20260722140000_config_empresa_meta_positivacao/migration.sql @@ -0,0 +1,5 @@ +-- Meta diaria de positivacao do painel gerencial - salva por empresa para +-- nao depender do navegador do gerente. +-- Idempotente (provision roda o schema espelho antes do migrate deploy). +-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui. +ALTER TABLE sar.config_empresa ADD COLUMN IF NOT EXISTS meta_positivacao_dia INTEGER; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4ced0d8..5e8730d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -201,9 +201,10 @@ model RegraDesconto { // impressão do pedido de todos os representantes. model ConfigEmpresa { - idEmpresa Int @id @map("id_empresa") - logoBase64 String? @map("logo_base64") - updatedAt DateTime @updatedAt @map("updated_at") + idEmpresa Int @id @map("id_empresa") + logoBase64 String? @map("logo_base64") + metaPositivacaoDia Int? @map("meta_positivacao_dia") + updatedAt DateTime @updatedAt @map("updated_at") @@map("config_empresa") } diff --git a/apps/api/src/app/dashboard/dashboard.controller.ts b/apps/api/src/app/dashboard/dashboard.controller.ts index 060e0f0..1a94922 100644 --- a/apps/api/src/app/dashboard/dashboard.controller.ts +++ b/apps/api/src/app/dashboard/dashboard.controller.ts @@ -1,9 +1,18 @@ -import { Controller, ForbiddenException, Get, Query } from '@nestjs/common'; +import { Body, Controller, ForbiddenException, Get, HttpCode, Put, Query } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; -import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface'; +import { createZodDto } from 'nestjs-zod'; +import { + SaveMetaPositivacaoBodySchema, + type RepDashboard, + type SupervisorDashboard, + type ManagerDashboard, + type SaveMetaPositivacaoBody, +} from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { DashboardService } from './dashboard.service'; +class SaveMetaPositivacaoDto extends createZodDto(SaveMetaPositivacaoBodySchema) {} + @Controller({ path: 'dashboard' }) export class DashboardController { constructor( @@ -31,6 +40,14 @@ export class DashboardController { return this.dashboard.supervisorDashboard(); } + @Put('manager/meta-positivacao-dia') + @HttpCode(204) + saveMetaPositivacaoDia(@Body() body: SaveMetaPositivacaoDto): Promise { + this.assertGestor(); + const parsed = SaveMetaPositivacaoBodySchema.parse(body) as SaveMetaPositivacaoBody; + return this.dashboard.saveMetaPositivacaoDia(parsed.meta); + } + @Get('manager') managerDashboard( @Query('mes') mes?: string, diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 6ad2075..15923a8 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import type { RepDashboard, @@ -605,6 +605,11 @@ export class DashboardService { const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size; const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0); + // Meta diária de positivação salva pelo gerente (config da empresa matriz) + const config = await prisma.configEmpresa.findUnique({ + where: { idEmpresa: idEmpresaMatriz }, + }); + const novosMap = new Map( novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]), ); @@ -635,10 +640,29 @@ export class DashboardService { dia: r.dia, clientes: Number(r.clientes), })), + metaPositivacaoDia: config?.metaPositivacaoDia ?? null, syncedAt: now.toISOString(), }; } + // Salva a meta diária de positivação (config_empresa) — só gerente/admin. + async saveMetaPositivacaoDia(meta: number): Promise { + const role = this.cls.get('role') ?? 'rep'; + if (role !== 'manager' && role !== 'admin') { + throw new ForbiddenException('Apenas gerentes podem salvar a meta de positivação'); + } + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); + + const value = meta > 0 ? meta : null; + await prisma.configEmpresa.upsert({ + where: { idEmpresa }, + create: { idEmpresa, metaPositivacaoDia: value }, + update: { metaPositivacaoDia: value }, + }); + } + async supervisorDashboard(): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); diff --git a/apps/web/src/cockpits/ger/GerPainel.tsx b/apps/web/src/cockpits/ger/GerPainel.tsx index fbdcbe0..1f38a14 100644 --- a/apps/web/src/cockpits/ger/GerPainel.tsx +++ b/apps/web/src/cockpits/ger/GerPainel.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Button, Card, @@ -13,6 +14,7 @@ import { Table, Tag, Typography, + message, } from 'antd'; import { useNavigate } from '@tanstack/react-router'; import type { TableColumnsType } from 'antd'; @@ -43,6 +45,7 @@ import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface'; import { useManagerDashboard } from '../../lib/queries/gerente'; +import { apiFetch } from '../../lib/api-client'; ChartJS.register( CategoryScale, @@ -56,9 +59,6 @@ ChartJS.register( const { Title, Text } = Typography; -// Meta diária de positivação é preferência local do gerente (não vem do ERP) -const META_POSITIVACAO_DIA_KEY = 'sar:ger:meta-positivacao-dia'; - function fmt(v: number): string { return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); } @@ -229,25 +229,31 @@ const rankingColumns: TableColumnsType = [ function PositivacaoDiariaCard({ dados, + metaSalva, periodo, periodoLabel, isMesAtual, }: { dados: PositivacaoDia[]; + metaSalva: number; periodo: Dayjs; periodoLabel: string; isMesAtual: boolean; }) { - const [meta, setMeta] = useState(() => { - const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY)); - return Number.isFinite(saved) && saved > 0 ? saved : 0; - }); + // Meta salva no servidor (config_empresa) — vale em qualquer dispositivo + const [meta, setMeta] = useState(metaSalva); + useEffect(() => setMeta(metaSalva), [metaSalva]); + const qc = useQueryClient(); + const [msg, msgCtx] = message.useMessage(); - function changeMeta(v: number | null) { - const val = v ?? 0; - setMeta(val); - localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val)); - } + const salvarMeta = useMutation({ + mutationFn: (m: number) => + apiFetch('/dashboard/manager/meta-positivacao-dia', { method: 'PUT', body: { meta: m } }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] }); + void msg.success('Meta salva'); + }, + }); // Eixo com todos os dias: até hoje no mês atual, mês inteiro nos anteriores const ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date(); @@ -334,13 +340,23 @@ function PositivacaoDiariaCard({ min={0} size="small" value={meta > 0 ? meta : undefined} - onChange={changeMeta} + onChange={(v) => setMeta(v ?? 0)} placeholder="—" style={{ width: 80 }} /> + } > + {msgCtx}
@@ -645,6 +661,7 @@ export function GerPainel() { {/* Positivação Diária */} ; + +// Salvar a meta diária de positivação (0 limpa a meta) +export const SaveMetaPositivacaoBodySchema = z.object({ + meta: z.number().int().min(0).max(100000), +}); +export type SaveMetaPositivacaoBody = z.infer; diff --git a/scripts/sar-erp-schema.sql b/scripts/sar-erp-schema.sql index 1795d85..20f6d67 100644 --- a/scripts/sar-erp-schema.sql +++ b/scripts/sar-erp-schema.sql @@ -743,9 +743,10 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto ( -- logo_base64: data URL (data:image/png;base64,...) -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS sar.config_empresa ( - id_empresa INTEGER PRIMARY KEY, - logo_base64 TEXT, - updated_at TIMESTAMP NOT NULL DEFAULT NOW() + id_empresa INTEGER PRIMARY KEY, + logo_base64 TEXT, + meta_positivacao_dia INTEGER, + updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); -- -----------------------------------------------------------------------------