feat(api,web): meta de positivacao diaria salva no servidor com botao Salvar

Antes a meta vivia no localStorage do navegador (perdia em outro
dispositivo). Agora: coluna meta_positivacao_dia em config_empresa,
PUT /dashboard/manager/meta-positivacao-dia (gerente/admin), meta vem
no dashboard e o card tem botao Salvar (desabilitado sem mudanca).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:33:38 +00:00
parent b6b16e1e21
commit 725a94ede1
7 changed files with 96 additions and 23 deletions

View File

@@ -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;

View File

@@ -201,9 +201,10 @@ model RegraDesconto {
// impressão do pedido de todos os representantes. // impressão do pedido de todos os representantes.
model ConfigEmpresa { model ConfigEmpresa {
idEmpresa Int @id @map("id_empresa") idEmpresa Int @id @map("id_empresa")
logoBase64 String? @map("logo_base64") logoBase64 String? @map("logo_base64")
updatedAt DateTime @updatedAt @map("updated_at") metaPositivacaoDia Int? @map("meta_positivacao_dia")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("config_empresa") @@map("config_empresa")
} }

View File

@@ -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 { 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 type { WorkspaceClsStore } from '../workspace/workspace.types';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
class SaveMetaPositivacaoDto extends createZodDto(SaveMetaPositivacaoBodySchema) {}
@Controller({ path: 'dashboard' }) @Controller({ path: 'dashboard' })
export class DashboardController { export class DashboardController {
constructor( constructor(
@@ -31,6 +40,14 @@ export class DashboardController {
return this.dashboard.supervisorDashboard(); return this.dashboard.supervisorDashboard();
} }
@Put('manager/meta-positivacao-dia')
@HttpCode(204)
saveMetaPositivacaoDia(@Body() body: SaveMetaPositivacaoDto): Promise<void> {
this.assertGestor();
const parsed = SaveMetaPositivacaoBodySchema.parse(body) as SaveMetaPositivacaoBody;
return this.dashboard.saveMetaPositivacaoDia(parsed.meta);
}
@Get('manager') @Get('manager')
managerDashboard( managerDashboard(
@Query('mes') mes?: string, @Query('mes') mes?: string,

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { import type {
RepDashboard, RepDashboard,
@@ -605,6 +605,11 @@ export class DashboardService {
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size; const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0); 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( const novosMap = new Map(
novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]), novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]),
); );
@@ -635,10 +640,29 @@ export class DashboardService {
dia: r.dia, dia: r.dia,
clientes: Number(r.clientes), clientes: Number(r.clientes),
})), })),
metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
syncedAt: now.toISOString(), syncedAt: now.toISOString(),
}; };
} }
// Salva a meta diária de positivação (config_empresa) — só gerente/admin.
async saveMetaPositivacaoDia(meta: number): Promise<void> {
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<SupervisorDashboard> { async supervisorDashboard(): Promise<SupervisorDashboard> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
Button, Button,
Card, Card,
@@ -13,6 +14,7 @@ import {
Table, Table,
Tag, Tag,
Typography, Typography,
message,
} from 'antd'; } from 'antd';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
@@ -43,6 +45,7 @@ import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface'; import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
import { useManagerDashboard } from '../../lib/queries/gerente'; import { useManagerDashboard } from '../../lib/queries/gerente';
import { apiFetch } from '../../lib/api-client';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@@ -56,9 +59,6 @@ ChartJS.register(
const { Title, Text } = Typography; 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 { function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
} }
@@ -229,25 +229,31 @@ const rankingColumns: TableColumnsType<RankingRep> = [
function PositivacaoDiariaCard({ function PositivacaoDiariaCard({
dados, dados,
metaSalva,
periodo, periodo,
periodoLabel, periodoLabel,
isMesAtual, isMesAtual,
}: { }: {
dados: PositivacaoDia[]; dados: PositivacaoDia[];
metaSalva: number;
periodo: Dayjs; periodo: Dayjs;
periodoLabel: string; periodoLabel: string;
isMesAtual: boolean; isMesAtual: boolean;
}) { }) {
const [meta, setMeta] = useState<number>(() => { // Meta salva no servidor (config_empresa) — vale em qualquer dispositivo
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY)); const [meta, setMeta] = useState<number>(metaSalva);
return Number.isFinite(saved) && saved > 0 ? saved : 0; useEffect(() => setMeta(metaSalva), [metaSalva]);
}); const qc = useQueryClient();
const [msg, msgCtx] = message.useMessage();
function changeMeta(v: number | null) { const salvarMeta = useMutation({
const val = v ?? 0; mutationFn: (m: number) =>
setMeta(val); apiFetch('/dashboard/manager/meta-positivacao-dia', { method: 'PUT', body: { meta: m } }),
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val)); 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 // 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(); const ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date();
@@ -334,13 +340,23 @@ function PositivacaoDiariaCard({
min={0} min={0}
size="small" size="small"
value={meta > 0 ? meta : undefined} value={meta > 0 ? meta : undefined}
onChange={changeMeta} onChange={(v) => setMeta(v ?? 0)}
placeholder="—" placeholder="—"
style={{ width: 80 }} style={{ width: 80 }}
/> />
<Button
type="primary"
size="small"
disabled={meta === metaSalva}
loading={salvarMeta.isPending}
onClick={() => salvarMeta.mutate(meta)}
>
Salvar
</Button>
</Flex> </Flex>
} }
> >
{msgCtx}
<div style={{ height: 280 }}> <div style={{ height: 280 }}>
<Chart type="bar" data={chartData} options={options} /> <Chart type="bar" data={chartData} options={options} />
</div> </div>
@@ -645,6 +661,7 @@ export function GerPainel() {
{/* Positivação Diária */} {/* Positivação Diária */}
<PositivacaoDiariaCard <PositivacaoDiariaCard
dados={positivacaoDiaria} dados={positivacaoDiaria}
metaSalva={data.metaPositivacaoDia ?? 0}
periodo={periodo} periodo={periodo}
periodoLabel={periodoLabel} periodoLabel={periodoLabel}
isMesAtual={isMesAtual} isMesAtual={isMesAtual}

View File

@@ -125,6 +125,14 @@ export const ManagerDashboardSchema = z.object({
rankingReps: z.array(RankingRepSchema), rankingReps: z.array(RankingRepSchema),
positivacaoReps: z.array(PositivacaoRepSchema), positivacaoReps: z.array(PositivacaoRepSchema),
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]), positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
// Meta diária de positivação salva pelo gerente (config_empresa)
metaPositivacaoDia: z.number().int().nullable().optional(),
syncedAt: z.iso.datetime(), syncedAt: z.iso.datetime(),
}); });
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>; export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;
// 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<typeof SaveMetaPositivacaoBodySchema>;

View File

@@ -743,9 +743,10 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
-- logo_base64: data URL (data:image/png;base64,...) -- logo_base64: data URL (data:image/png;base64,...)
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sar.config_empresa ( CREATE TABLE IF NOT EXISTS sar.config_empresa (
id_empresa INTEGER PRIMARY KEY, id_empresa INTEGER PRIMARY KEY,
logo_base64 TEXT, logo_base64 TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT NOW() meta_positivacao_dia INTEGER,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
); );
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------