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:
@@ -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;
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user