feat(api,web): regras de desconto por pauta e cards de supervisor na equipe
Regras de Desconto (Politicas Comerciais): - Nova dimensao de escopo por pauta de precos: cria 1 regra por pauta selecionada (idPautas[]), aplicada a todos os produtos da pauta. - Coluna id_pauta em sar.regras_desconto (migration idempotente + schema canonico); motor de alcada (orders) casa regra<->pedido por idPauta. - Remove a aba/endpoints "Desconto por Representante" (gestao de alcada_desconto); o teto por rep continua no motor (default 5%) e no carrinho (/politicas/alcada), agora liberado via Regra de Desconto. Aba Equipe do gerente: - GET /equipe/supervisores: cards de disputa por supervisor (faturamento, meta, positivacao, variacao mensal, reps ativos). - SupervisorDetalheModal: detalhe do time (ranking + positivacao) reusando /dashboard/supervisor escopado por codSupervisor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import type { RepStats } from '@sar/api-interface';
|
||||
import { useEquipe } from '../../lib/queries/gerente';
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Col, Empty, Flex, Progress, Row, Skeleton, Space, Typography } from 'antd';
|
||||
import type { SupervisorCard } from '@sar/api-interface';
|
||||
import { useSupervisores } from '../../lib/queries/gerente';
|
||||
import { SupervisorDetalheModal } from './SupervisorDetalheModal';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -9,85 +10,118 @@ function fmt(v: number): string {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<RepStats> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: RepStats) => (
|
||||
<Flex vertical gap={0}>
|
||||
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||
{row.nomeVendedor && (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
cód. {row.codVendedor}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'pedidosMes',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
sorter: (a, b) => a.pedidosMes - b.pedidosMes,
|
||||
render: (v: number) => (v === 0 ? <Tag>0</Tag> : v),
|
||||
},
|
||||
{
|
||||
title: 'Faturamento',
|
||||
dataIndex: 'faturamentoMes',
|
||||
width: 160,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
sorter: (a, b) => a.faturamentoMes - b.faturamentoMes,
|
||||
defaultSortOrder: 'descend',
|
||||
render: (v: number) => fmt(v),
|
||||
},
|
||||
{
|
||||
title: 'Ticket Medio',
|
||||
dataIndex: 'ticketMedio',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
sorter: (a, b) => a.ticketMedio - b.ticketMedio,
|
||||
render: (v: number) => (v > 0 ? fmt(v) : <Text type="secondary">--</Text>),
|
||||
},
|
||||
{
|
||||
title: '% Meta',
|
||||
dataIndex: 'pctMeta',
|
||||
width: 170,
|
||||
sorter: (a, b) => a.pctMeta - b.pctMeta,
|
||||
render: (pct: number) =>
|
||||
pct === 0 ? (
|
||||
<Text type="secondary">sem meta</Text>
|
||||
) : (
|
||||
<Flex align="center" gap={8}>
|
||||
<Progress
|
||||
percent={Math.min(pct, 100)}
|
||||
size="small"
|
||||
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||
showInfo={false}
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
function metaColor(pct: number): string {
|
||||
return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
|
||||
}
|
||||
|
||||
const MEDALHAS = ['🥇', '🥈', '🥉'];
|
||||
|
||||
function posicaoLabel(i: number): string {
|
||||
return MEDALHAS[i] ?? `${i + 1}º`;
|
||||
}
|
||||
|
||||
function SupervisorCardView({
|
||||
s,
|
||||
posicao,
|
||||
onDetalhar,
|
||||
}: {
|
||||
s: SupervisorCard;
|
||||
posicao: number;
|
||||
onDetalhar: () => void;
|
||||
}) {
|
||||
const lider = posicao === 0;
|
||||
return (
|
||||
<Card
|
||||
style={lider ? { borderColor: 'var(--jcs-blue)', borderWidth: 2 } : undefined}
|
||||
styles={{ body: { padding: 'var(--space-lg)' } }}
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
{/* Cabeçalho: posição + nome */}
|
||||
<Flex align="center" gap={10}>
|
||||
<span style={{ fontSize: 22, lineHeight: 1, minWidth: 30, textAlign: 'center' }}>
|
||||
{posicaoLabel(posicao)}
|
||||
</span>
|
||||
<Flex vertical gap={0} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text strong ellipsis style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{s.nomeSupervisor ?? `Supervisor cód. ${s.codSupervisor}`}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
cód. {s.codSupervisor} · {s.repsAtivos}/{s.repsTotal} reps ativos
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
{/* Faturamento + variação */}
|
||||
<Flex align="baseline" gap={10} wrap>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(s.faturamentoMes)}
|
||||
</Title>
|
||||
{s.variacaoPct != null && (
|
||||
<Text
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: s.variacaoPct >= 0 ? 'var(--green)' : '#cf1322',
|
||||
}}
|
||||
>
|
||||
{s.variacaoPct >= 0 ? '▲' : '▼'} {s.variacaoPct >= 0 ? '+' : ''}
|
||||
{s.variacaoPct}% vs. mês ant.
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Meta do time */}
|
||||
<div>
|
||||
<Flex justify="space-between" align="baseline" style={{ marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
Meta do time
|
||||
</Text>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{s.metaTotal > 0 ? `${s.pctMeta}%` : 'sem meta'}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Progress
|
||||
percent={s.metaTotal > 0 ? Math.min(s.pctMeta, 100) : 0}
|
||||
size="small"
|
||||
strokeColor={metaColor(s.pctMeta)}
|
||||
showInfo={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ticket + positivação */}
|
||||
<Flex justify="space-between" gap={12}>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
TICKET MÉDIO
|
||||
</Text>
|
||||
<Text strong className="tabular-nums">
|
||||
{s.ticketMedio > 0 ? fmt(s.ticketMedio) : '—'}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space orientation="vertical" size={0} style={{ textAlign: 'right' }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
POSITIVAÇÃO
|
||||
</Text>
|
||||
<Text strong className="tabular-nums">
|
||||
{s.pctPositivacao}%{' '}
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
({s.clientesPositivados}/{s.totalClientes})
|
||||
</Text>
|
||||
</Text>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<Button block onClick={onDetalhar}>
|
||||
Detalhar
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipePage() {
|
||||
const { data, isLoading } = useEquipe();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
<Skeleton active paragraph={{ rows: 1 }} />
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
const { data, isLoading } = useSupervisores();
|
||||
const [detalhe, setDetalhe] = useState<SupervisorCard | null>(null);
|
||||
|
||||
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||
|
||||
@@ -98,25 +132,41 @@ export function EquipePage() {
|
||||
Equipe
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Performance dos representantes — {mes}
|
||||
Disputa dos supervisores — {mes}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Card>
|
||||
<Table<RepStats>
|
||||
rowKey="codVendedor"
|
||||
columns={columns}
|
||||
dataSource={data.reps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum representante encontrado.' }}
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
</Card>
|
||||
{isLoading || !data ? (
|
||||
<Row gutter={[24, 24]}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Col key={i} xs={24} md={12} lg={8}>
|
||||
<Card>
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
) : data.supervisores.length === 0 ? (
|
||||
<Card>
|
||||
<Empty description="Nenhum supervisor com equipe encontrado." />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[24, 24]}>
|
||||
{data.supervisores.map((s: SupervisorCard, i: number) => (
|
||||
<Col key={s.codSupervisor} xs={24} md={12} lg={8}>
|
||||
<SupervisorCardView s={s} posicao={i} onDetalhar={() => setDetalhe(s)} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SupervisorDetalheModal supervisor={detalhe} onClose={() => setDetalhe(null)} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,17 +24,9 @@ import type { TableColumnsType } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type {
|
||||
AlcadaDescontoItem,
|
||||
Promocao,
|
||||
RegraDesconto,
|
||||
RepStats,
|
||||
GrupoProdutoItem,
|
||||
} from '@sar/api-interface';
|
||||
import type { Promocao, RegraDesconto, RepStats, GrupoProdutoItem } from '@sar/api-interface';
|
||||
import {
|
||||
usePoliticasDescontos,
|
||||
usePromocoes,
|
||||
useUpsertDesconto,
|
||||
useCreatePromocao,
|
||||
useUpdatePromocao,
|
||||
useDeletePromocao,
|
||||
@@ -45,7 +37,7 @@ import {
|
||||
useUpdateRegraDesconto,
|
||||
useDeleteRegraDesconto,
|
||||
} from '../../lib/queries/gerente';
|
||||
import { useCatalog } from '../../lib/queries/catalog';
|
||||
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
||||
|
||||
// ─── ProdutoSelect: busca remota por código ou descrição ─────────────────────
|
||||
|
||||
@@ -104,125 +96,6 @@ function ProdutoSelect({
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// ─── Tab Descontos ────────────────────────────────────────────────────────────
|
||||
|
||||
function TabDescontos() {
|
||||
const { data, isLoading } = usePoliticasDescontos();
|
||||
const upsert = useUpsertDesconto();
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState<number>(0);
|
||||
const [msg, msgCtx] = message.useMessage();
|
||||
|
||||
function startEdit(row: AlcadaDescontoItem) {
|
||||
setEditingKey(`${row.codVendedor}-${row.codGrupo}`);
|
||||
setEditValue(row.limitePerc);
|
||||
}
|
||||
|
||||
async function saveEdit(row: AlcadaDescontoItem) {
|
||||
try {
|
||||
await upsert.mutateAsync({
|
||||
codVendedor: row.codVendedor,
|
||||
codGrupo: row.codGrupo,
|
||||
limitePerc: editValue,
|
||||
});
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient
|
||||
}
|
||||
setEditingKey(null);
|
||||
void msg.success('Limite atualizado');
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AlcadaDescontoItem> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: AlcadaDescontoItem) =>
|
||||
row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||
},
|
||||
{
|
||||
title: 'Grupo',
|
||||
dataIndex: 'codGrupo',
|
||||
width: 100,
|
||||
render: (v: number) => (v === 0 ? <Tag>Global</Tag> : <Tag color="blue">Grupo {v}</Tag>),
|
||||
},
|
||||
{
|
||||
title: 'Limite de Desconto',
|
||||
dataIndex: 'limitePerc',
|
||||
width: 200,
|
||||
render: (v: number, row: AlcadaDescontoItem) => {
|
||||
const key = `${row.codVendedor}-${row.codGrupo}`;
|
||||
if (editingKey === key) {
|
||||
return (
|
||||
<InputNumber
|
||||
value={editValue}
|
||||
onChange={(val) => setEditValue(val ?? 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
suffix="%"
|
||||
size="small"
|
||||
style={{ width: 100 }}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Text className="tabular-nums">{v}%</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 120,
|
||||
render: (_: unknown, row: AlcadaDescontoItem) => {
|
||||
const key = `${row.codVendedor}-${row.codGrupo}`;
|
||||
if (editingKey === key) {
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={upsert.isPending}
|
||||
onClick={() => void saveEdit(row)}
|
||||
>
|
||||
Salvar
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setEditingKey(null)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FontAwesomeIcon icon={faPen} />}
|
||||
onClick={() => startEdit(row)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Table<AlcadaDescontoItem>
|
||||
rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`}
|
||||
columns={columns}
|
||||
dataSource={data.descontos}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum limite configurado.' }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
||||
Limite 0 = sem restricao de desconto para este representante.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab Promocoes ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PromocaoForm {
|
||||
@@ -534,8 +407,10 @@ interface RegraForm {
|
||||
codVendedor?: number;
|
||||
codGrupos?: number[];
|
||||
codSubgrupos?: number[];
|
||||
idPautas?: number[];
|
||||
codGrupo?: number;
|
||||
codSubgrupo?: number;
|
||||
idPauta?: number;
|
||||
descPct: number;
|
||||
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||
ativa?: boolean;
|
||||
@@ -545,6 +420,7 @@ function TabRegras() {
|
||||
const { data, isLoading } = useRegrasDesconto();
|
||||
const { data: equipe } = useEquipe();
|
||||
const { data: gruposData } = useGruposProduto();
|
||||
const { data: pautas } = usePautas();
|
||||
const createMutation = useCreateRegraDesconto();
|
||||
const updateMutation = useUpdateRegraDesconto();
|
||||
const deleteMutation = useDeleteRegraDesconto();
|
||||
@@ -560,6 +436,12 @@ function TabRegras() {
|
||||
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
||||
}));
|
||||
|
||||
// Pautas de preços — rótulo com código + descrição (busca encontra pelos dois)
|
||||
const pautaOptions = (pautas ?? []).map((p) => ({
|
||||
value: p.idPauta,
|
||||
label: `${p.codigo} — ${p.descricao}`,
|
||||
}));
|
||||
|
||||
// Rótulos com código + nome — a busca do select encontra pelos dois.
|
||||
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
|
||||
const filtroGrupos = new Set<number>([
|
||||
@@ -603,6 +485,7 @@ function TabRegras() {
|
||||
codVendedor: r.codVendedor ?? undefined,
|
||||
codGrupo: r.codGrupo ?? undefined,
|
||||
codSubgrupo: r.codSubgrupo ?? undefined,
|
||||
idPauta: r.idPauta ?? undefined,
|
||||
descPct: r.descPct,
|
||||
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
||||
ativa: r.ativa,
|
||||
@@ -630,6 +513,7 @@ function TabRegras() {
|
||||
codVendedor: values.codVendedor ?? null,
|
||||
codGrupo: values.codGrupo ?? null,
|
||||
codSubgrupo: values.codSubgrupo ?? null,
|
||||
idPauta: values.idPauta ?? null,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
@@ -643,6 +527,7 @@ function TabRegras() {
|
||||
codVendedor: values.codVendedor ?? null,
|
||||
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
|
||||
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
|
||||
idPautas: values.idPautas?.length ? values.idPautas : undefined,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
@@ -683,7 +568,7 @@ function TabRegras() {
|
||||
},
|
||||
{
|
||||
title: 'Grupo / Subgrupo',
|
||||
width: 220,
|
||||
width: 200,
|
||||
render: (_: unknown, row: RegraDesconto) => {
|
||||
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
|
||||
const partes = [
|
||||
@@ -693,6 +578,16 @@ function TabRegras() {
|
||||
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Pauta',
|
||||
width: 160,
|
||||
render: (_: unknown, row: RegraDesconto) =>
|
||||
row.idPauta == null ? (
|
||||
<Tag>Todas</Tag>
|
||||
) : (
|
||||
<Text style={{ fontSize: 'var(--text-sm)' }}>{row.pauta ?? `Pauta ${row.idPauta}`}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Desconto',
|
||||
dataIndex: 'descPct',
|
||||
@@ -782,8 +677,8 @@ function TabRegras() {
|
||||
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
||||
A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica
|
||||
o desconto automaticamente nos pedidos durante a vigencia.
|
||||
A regra vale para o representante, grupo, subgrupo e pauta informados (em branco = todos) e
|
||||
aplica o desconto automaticamente nos pedidos durante a vigencia.
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
@@ -817,30 +712,41 @@ function TabRegras() {
|
||||
</Form.Item>
|
||||
|
||||
{editTarget ? (
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<>
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={grupoOptions}
|
||||
placeholder="Todos os grupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={subgrupoOptions}
|
||||
placeholder="Todos os subgrupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
<Form.Item name="idPauta" label="Pauta de precos">
|
||||
<Select
|
||||
options={grupoOptions}
|
||||
placeholder="Todos os grupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={subgrupoOptions}
|
||||
placeholder="Todos os subgrupos"
|
||||
options={pautaOptions}
|
||||
placeholder="Todas as pautas"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
@@ -871,6 +777,20 @@ function TabRegras() {
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="idPautas"
|
||||
label="Pautas de precos"
|
||||
extra="Selecione 1 ou mais pautas — uma regra por pauta, aplicada a todos os produtos dela"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={pautaOptions}
|
||||
placeholder="Todas as pautas"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -903,11 +823,6 @@ function TabRegras() {
|
||||
|
||||
export function PoliticasPage() {
|
||||
const items = [
|
||||
{
|
||||
key: 'descontos',
|
||||
label: 'Desconto por Representante',
|
||||
children: <TabDescontos />,
|
||||
},
|
||||
{
|
||||
key: 'promocoes',
|
||||
label: 'Promocoes',
|
||||
@@ -927,7 +842,7 @@ export function PoliticasPage() {
|
||||
Politicas Comerciais
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Limites de desconto e promocoes com vigencia
|
||||
Promocoes e regras de desconto com vigencia
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
|
||||
164
apps/web/src/cockpits/ger/SupervisorDetalheModal.tsx
Normal file
164
apps/web/src/cockpits/ger/SupervisorDetalheModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Card, Col, Flex, Modal, Progress, Row, Space, Spin, Table, Typography } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faAddressCard, faRankingStar } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { PositivacaoRep, RankingRep, SupervisorCard } from '@sar/api-interface';
|
||||
import { useSupervisorDashboardByCod } from '../../lib/queries/dashboard';
|
||||
import {
|
||||
positivacaoRepColumns,
|
||||
rankingRepColumns,
|
||||
} from '../../components/dashboard/rep-performance-columns';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
function fmt(v: number): string {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function metaColor(pct: number): string {
|
||||
return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
|
||||
}
|
||||
|
||||
// Detalhe/análise do time de um supervisor — aberto pelo gerente na aba Equipe.
|
||||
// Reusa /dashboard/supervisor escopado por codSupervisor e as mesmas tabelas do
|
||||
// painel do supervisor (ranking + positivação).
|
||||
export function SupervisorDetalheModal({
|
||||
supervisor,
|
||||
onClose,
|
||||
}: {
|
||||
supervisor: SupervisorCard | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data, isLoading } = useSupervisorDashboardByCod(supervisor?.codSupervisor ?? null);
|
||||
|
||||
const titulo = supervisor
|
||||
? `${supervisor.nomeSupervisor ?? `Supervisor cód. ${supervisor.codSupervisor}`}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={supervisor !== null}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={900}
|
||||
centered
|
||||
destroyOnHidden
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faRankingStar} style={{ color: 'var(--jcs-blue)' }} />
|
||||
{titulo}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{isLoading || !data || !supervisor ? (
|
||||
<Spin style={{ display: 'block', margin: '48px auto' }} />
|
||||
) : (
|
||||
<Flex vertical gap={20}>
|
||||
{/* KPIs do time no mês */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card size="small">
|
||||
<Space orientation="vertical" size={2}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
FATURAMENTO NO MÊS
|
||||
</Text>
|
||||
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(data.equipeMes.faturamento)}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{data.equipeMes.pedidos} pedido{data.equipeMes.pedidos !== 1 ? 's' : ''} ·{' '}
|
||||
{supervisor.repsAtivos}/{supervisor.repsTotal} reps ativos
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card size="small">
|
||||
<Space orientation="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
META DO TIME
|
||||
</Text>
|
||||
{data.equipeMes.metaTotal > 0 ? (
|
||||
<>
|
||||
<Flex align="baseline" gap={6}>
|
||||
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
|
||||
{data.equipeMes.pctMeta}%
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
de {fmt(data.equipeMes.metaTotal)}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Progress
|
||||
percent={Math.min(data.equipeMes.pctMeta, 100)}
|
||||
size="small"
|
||||
strokeColor={metaColor(data.equipeMes.pctMeta)}
|
||||
showInfo={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary">Sem meta cadastrada.</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card size="small">
|
||||
<Space orientation="vertical" size={2}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
TICKET MÉDIO
|
||||
</Text>
|
||||
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(data.equipeMes.ticketMedio)}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
positivação {supervisor.pctPositivacao}% ({supervisor.clientesPositivados}/
|
||||
{supervisor.totalClientes})
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Ranking dos reps do time */}
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 12 }}>
|
||||
<FontAwesomeIcon
|
||||
icon={faRankingStar}
|
||||
style={{ color: 'var(--jcs-blue)', marginRight: 8 }}
|
||||
/>
|
||||
Ranking dos representantes
|
||||
</Text>
|
||||
<Table<RankingRep>
|
||||
rowKey="codVendedor"
|
||||
columns={rankingRepColumns}
|
||||
dataSource={data.rankingReps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 640 }}
|
||||
locale={{ emptyText: 'Nenhum pedido do time no mês.' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Positivação dos reps do time */}
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 12 }}>
|
||||
<FontAwesomeIcon
|
||||
icon={faAddressCard}
|
||||
style={{ color: 'var(--jcs-blue)', marginRight: 8 }}
|
||||
/>
|
||||
Positivação de clientes
|
||||
</Text>
|
||||
<Table<PositivacaoRep>
|
||||
rowKey="codVendedor"
|
||||
columns={positivacaoRepColumns}
|
||||
dataSource={data.positivacaoReps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 640 }}
|
||||
locale={{ emptyText: 'Nenhum representante com carteira no time.' }}
|
||||
/>
|
||||
</div>
|
||||
</Flex>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,20 @@ export function useSupervisorDashboard() {
|
||||
});
|
||||
}
|
||||
|
||||
// Painel de um supervisor específico — gerente/admin abrindo o "Detalhar" da
|
||||
// aba Equipe. Reusa /dashboard/supervisor escopando pelo codSupervisor.
|
||||
export function useSupervisorDashboardByCod(codSupervisor: number | null) {
|
||||
return useQuery<SupervisorDashboard>({
|
||||
queryKey: ['dashboard', 'supervisor', 'detalhe', codSupervisor],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch(`/dashboard/supervisor?codSupervisor=${codSupervisor}`);
|
||||
return SupervisorDashboardSchema.parse(raw);
|
||||
},
|
||||
enabled: codSupervisor != null,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Detalhe dos clientes inativos de um rep (modal do card "Inativos por Rep")
|
||||
export function useRepInativos(codVendedor: number | null) {
|
||||
return useQuery<RepInativosDetail>({
|
||||
|
||||
@@ -2,15 +2,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ManagerDashboardSchema,
|
||||
EquipeResponseSchema,
|
||||
AlcadaDescontosResponseSchema,
|
||||
SupervisoresResponseSchema,
|
||||
PromocoesResponseSchema,
|
||||
RegrasDescontoResponseSchema,
|
||||
GruposProdutoResponseSchema,
|
||||
type ManagerDashboard,
|
||||
type EquipeResponse,
|
||||
type AlcadaDescontosResponse,
|
||||
type SupervisoresResponse,
|
||||
type PromocoesResponse,
|
||||
type UpsertDescontoBody,
|
||||
type CreatePromocaoBody,
|
||||
type UpdatePromocaoBody,
|
||||
type RegrasDescontoResponse,
|
||||
@@ -47,14 +46,14 @@ export function useEquipe() {
|
||||
});
|
||||
}
|
||||
|
||||
export function usePoliticasDescontos() {
|
||||
return useQuery<AlcadaDescontosResponse>({
|
||||
queryKey: ['politicas', 'descontos'],
|
||||
export function useSupervisores() {
|
||||
return useQuery<SupervisoresResponse>({
|
||||
queryKey: ['equipe', 'supervisores'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/descontos');
|
||||
return AlcadaDescontosResponseSchema.parse(raw);
|
||||
const raw = await apiFetch('/equipe/supervisores');
|
||||
return SupervisoresResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 10 * 60 * 1000,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,15 +68,6 @@ export function usePromocoes() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertDesconto() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: UpsertDescontoBody) =>
|
||||
apiFetch('/politicas/descontos', { method: 'POST', body }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePromocao() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
Reference in New Issue
Block a user