feat(web+api): cockpit gerente — painel, equipe, políticas e positivação

## API
- GET /dashboard/manager: KPIs agregados (faturamento, pedidos, ticket médio,
  promoções ativas), meta total do período, ranking top 10 com clientes
  atendidos e % meta, positivação por representante, venda necessária/dia
- Parâmetros opcionais ?mes&ano para filtrar período
- GET /equipe: lista de reps com pedidos, faturamento, ticket médio e % meta
  (deduplicação de vw_representantes)
- GET/POST /politicas/descontos: alçada de desconto por rep (AlcadaDesconto)
- GET/POST/PATCH/DELETE /politicas/promocoes: CRUD de promoções com validade
- Metas lidas de sar.vw_metas (tipo=GR), não de vw_metas do ERP

## Schema
- Novo model Promocao (sar.promocoes) criado via prisma db execute

## Frontend
- Cockpit /ger: GerPainel, EquipePage, PoliticasPage
- GerPainel: filtro mes/ano, cards KPI, cards de meta vs realizado
  (atingimento, falta, venda/dia), ranking com clientes atendidos,
  positivação de clientes por representante (paginada)
- Sidebar e BottomNav role-aware: rep / supervisor / gerente (manager+admin)
- Clientes acessível ao gerente (carteira completa de todos os reps)
- Rotas: HomeRoute redireciona por role; /ger, /ger/equipe, /ger/politicas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 19:14:57 +00:00
parent 400fcb3360
commit 289c1a071e
30 changed files with 2270 additions and 171 deletions

View File

@@ -0,0 +1,122 @@
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';
const { Title, Text } = Typography;
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>
</Flex>
),
},
];
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 mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Equipe
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Performance dos representantes &mdash; {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>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
</Text>
</Flex>
);
}

View File

@@ -0,0 +1,428 @@
import { useState } from 'react';
import {
Button,
Card,
Col,
DatePicker,
Flex,
Progress,
Row,
Skeleton,
Space,
Table,
Typography,
} from 'antd';
import { useNavigate } from '@tanstack/react-router';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faChartBar,
faTags,
faUsers,
faFileInvoiceDollar,
faAddressCard,
faBullseye,
faArrowTrendUp,
} from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import type { RankingRep, PositivacaoRep } from '@sar/api-interface';
import { useManagerDashboard } from '../../lib/queries/gerente';
const { Title, Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
}
const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
ellipsis: true,
},
{
title: 'Positivados / Carteira',
key: 'pos',
width: 160,
align: 'center' as const,
render: (_: unknown, row: PositivacaoRep) => (
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{row.clientesPositivados} / {row.totalClientes}
</Text>
),
},
{
title: '% Positivação',
dataIndex: 'pctPositivacao',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
const rankingColumns: TableColumnsType<RankingRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientesAtendidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 160,
render: (pct: number) => (
<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>
</Flex>
),
},
];
export function GerPainel() {
const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
const mes = periodo.month() + 1;
const ano = periodo.year();
const { data, isLoading } = useManagerDashboard(mes, ano);
const navigate = useNavigate();
const periodoLabel = periodo
.toDate()
.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
const isMesAtual = mes === dayjs().month() + 1 && ano === dayjs().year();
if (isLoading || !data) {
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 1 }} />
<Row gutter={[24, 24]}>
{[1, 2, 3, 4].map((i) => (
<Col key={i} xs={24} sm={12} lg={6}>
<Skeleton active />
</Col>
))}
</Row>
</Flex>
);
}
const {
faturamentoMes,
pedidosMes,
ticketMedio,
totalReps,
promocoesAtivas,
metaTotal,
rankingReps,
positivacaoReps,
syncedAt,
} = data;
const pctMeta = metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0;
const faltaMeta = Math.max(0, metaTotal - faturamentoMes);
const superouMeta = faturamentoMes > metaTotal ? faturamentoMes - metaTotal : 0;
const metaCorColor =
pctMeta >= 100 ? 'var(--green)' : pctMeta >= 70 ? 'var(--jcs-blue)' : '#faad14';
// Dias restantes no período selecionado (incluindo hoje se for mês atual)
const hoje = dayjs();
const fimMes = periodo.endOf('month');
const diasRestantes = isMesAtual
? Math.max(0, fimMes.date() - hoje.date() + 1)
: periodo.isBefore(hoje, 'month')
? 0
: fimMes.date(); // mês futuro: todos os dias
const vendaPorDia = faltaMeta > 0 && diasRestantes > 0 ? faltaMeta / diasRestantes : 0;
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{/* Cabeçalho */}
<Flex align="flex-start" justify="space-between" wrap="wrap" gap={12}>
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Painel Gerencial
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}
</Text>
</Flex>
<Flex align="center" gap={8}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
Período:
</Text>
<DatePicker
picker="month"
value={periodo}
onChange={(d) => d && setPeriodo(d)}
format="MMM/YYYY"
allowClear={false}
disabledDate={(d) => d.isAfter(dayjs(), 'month')}
style={{ width: 130 }}
/>
</Flex>
</Flex>
{/* KPIs linha 1 */}
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(faturamentoMes)}
</Title>
<FontAwesomeIcon
icon={faFileInvoiceDollar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{pedidosMes}
</Title>
<FontAwesomeIcon
icon={faChartBar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
TICKET MÉDIO
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(ticketMedio)}
</Title>
<FontAwesomeIcon icon={faUsers} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PROMOÇÕES ATIVAS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{promocoesAtivas}
</Title>
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
</Flex>
</Card>
</Col>
</Row>
{/* KPIs linha 2 — Meta */}
{metaTotal > 0 && (
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(metaTotal)}
</Title>
<FontAwesomeIcon
icon={faBullseye}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={8}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
ATINGIMENTO DA META
</Text>
<FontAwesomeIcon
icon={faArrowTrendUp}
style={{ color: metaCorColor, opacity: 0.7 }}
/>
</Flex>
<Title
level={3}
style={{ margin: 0, color: metaCorColor }}
className="tabular-nums"
>
{pctMeta}%
</Title>
<Progress
percent={Math.min(pctMeta, 100)}
strokeColor={metaCorColor}
showInfo={false}
size="small"
/>
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
</Text>
<Title
level={3}
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
className="tabular-nums"
>
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
</Title>
{superouMeta > 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{pctMeta - 100}% acima da meta
</Text>
) : vendaPorDia > 0 ? (
<Text
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
>
{fmt(vendaPorDia)}/dia &mdash; {diasRestantes} dia
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
</Text>
) : diasRestantes === 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
período encerrado
</Text>
) : null}
</Flex>
</Card>
</Col>
</Row>
)}
{/* Ranking */}
<Card
title={
<Space>
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
Ranking de Representantes &mdash; {periodoLabel}
</Space>
}
extra={
<Flex align="center" gap={12}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
top 10 de {totalReps} rep{totalReps !== 1 ? 's' : ''} com pedidos
</Text>
<Button size="small" onClick={() => void navigate({ to: '/ger/equipe' })}>
Ver todos
</Button>
</Flex>
}
>
<Table<RankingRep>
rowKey="codVendedor"
columns={rankingColumns}
dataSource={rankingReps}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum pedido registrado neste período.' }}
/>
</Card>
{/* Positivação por Representante */}
<Card
title={
<Space>
<FontAwesomeIcon icon={faAddressCard} style={{ color: 'var(--jcs-blue)' }} />
Positivação de Clientes por Representante &mdash; {periodoLabel}
</Space>
}
extra={
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
</Text>
}
>
<Table<PositivacaoRep>
rowKey="codVendedor"
columns={positivacaoColumns}
dataSource={positivacaoReps}
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
size="small"
locale={{ emptyText: 'Nenhum dado de carteira encontrado.' }}
/>
</Card>
<Flex justify="space-between" style={{ paddingTop: 8 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR &middot; Força de Vendas &middot; Powered by JCS Sistemas
</Text>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
{isMesAtual && ' · atualiza a cada 60s'}
</Text>
</Flex>
</Flex>
);
}

View File

@@ -0,0 +1,393 @@
import { useState } from 'react';
import {
Button,
Card,
DatePicker,
Flex,
Form,
InputNumber,
Modal,
Popconfirm,
Skeleton,
Space,
Table,
Tabs,
Tag,
Tooltip,
Typography,
Input,
message,
} from 'antd';
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 } from '@sar/api-interface';
import {
usePoliticasDescontos,
usePromocoes,
useUpsertDesconto,
useCreatePromocao,
useUpdatePromocao,
useDeletePromocao,
} from '../../lib/queries/gerente';
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) {
await upsert.mutateAsync({
codVendedor: row.codVendedor,
codGrupo: row.codGrupo,
limitePerc: editValue,
});
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 {
descricao: string;
codProduto?: string;
grpProd?: string;
descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs];
}
function TabPromocoes() {
const { data, isLoading } = usePromocoes();
const createMutation = useCreatePromocao();
const updateMutation = useUpdatePromocao();
const deleteMutation = useDeletePromocao();
const [form] = Form.useForm<PromocaoForm>();
const [modalOpen, setModalOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Promocao | null>(null);
const [msg, msgCtx] = message.useMessage();
function openCreate() {
setEditTarget(null);
form.resetFields();
setModalOpen(true);
}
function openEdit(p: Promocao) {
setEditTarget(p);
form.setFieldsValue({
descricao: p.descricao,
codProduto: p.codProduto ?? undefined,
grpProd: p.grpProd ?? undefined,
descPct: p.descPct,
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
});
setModalOpen(true);
}
async function handleSubmit(values: PromocaoForm) {
const [inicio, fim] = values.periodo;
const body = {
descricao: values.descricao,
codProduto: values.codProduto || undefined,
grpProd: values.grpProd || undefined,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body });
void msg.success('Promocao atualizada');
} else {
await createMutation.mutateAsync(body);
void msg.success('Promocao criada');
}
setModalOpen(false);
}
async function handleDelete(id: number) {
await deleteMutation.mutateAsync(id);
void msg.success('Promocao removida');
}
const today = dayjs().format('YYYY-MM-DD');
const columns: TableColumnsType<Promocao> = [
{
title: 'Descricao',
dataIndex: 'descricao',
render: (v: string, row: Promocao) => (
<Flex vertical gap={2}>
<Text>{v}</Text>
{(row.codProduto || row.grpProd) && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{row.codProduto ? `Produto: ${row.codProduto}` : `Grupo: ${row.grpProd}`}
</Text>
)}
</Flex>
),
},
{
title: 'Desconto',
dataIndex: 'descPct',
width: 100,
align: 'right',
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
},
{
title: 'Vigencia',
width: 200,
render: (_: unknown, row: Promocao) => (
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')}
</Text>
),
},
{
title: 'Status',
width: 90,
render: (_: unknown, row: Promocao) => {
if (!row.ativa) return <Tag>Inativa</Tag>;
if (row.dataFim < today) return <Tag color="red">Expirada</Tag>;
if (row.dataInicio > today) return <Tag color="blue">Futura</Tag>;
return <Tag color="green">Ativa</Tag>;
},
},
{
title: '',
width: 100,
render: (_: unknown, row: Promocao) => (
<Space>
<Tooltip title="Editar">
<Button
size="small"
icon={<FontAwesomeIcon icon={faPen} />}
onClick={() => openEdit(row)}
/>
</Tooltip>
<Popconfirm
title="Remover promocao?"
okText="Sim"
cancelText="Nao"
onConfirm={() => void handleDelete(row.id)}
>
<Button
size="small"
danger
icon={<FontAwesomeIcon icon={faTrash} />}
loading={deleteMutation.isPending}
/>
</Popconfirm>
</Space>
),
},
];
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
return (
<>
{msgCtx}
<Flex justify="flex-end" style={{ marginBottom: 16 }}>
<Button type="primary" icon={<FontAwesomeIcon icon={faPlus} />} onClick={openCreate}>
Nova Promocao
</Button>
</Flex>
<Table<Promocao>
rowKey="id"
columns={columns}
dataSource={data.promocoes}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhuma promocao cadastrada.' }}
/>
<Modal
title={editTarget ? 'Editar Promocao' : 'Nova Promocao'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void form.validateFields().then(handleSubmit)}
okText={editTarget ? 'Salvar' : 'Criar'}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="descricao"
label="Descricao"
rules={[{ required: true, message: 'Informe a descricao' }]}
>
<Input placeholder="Ex.: Promocao de inverno" />
</Form.Item>
<Flex gap={16}>
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}>
<Input placeholder="Opcional" />
</Form.Item>
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
<Input placeholder="Opcional" />
</Form.Item>
</Flex>
<Form.Item
name="descPct"
label="Desconto (%)"
rules={[{ required: true, message: 'Informe o desconto' }]}
>
<InputNumber min={0} max={100} suffix="%" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="periodo"
label="Periodo de vigencia"
rules={[{ required: true, message: 'Informe o periodo' }]}
>
<DatePicker.RangePicker
format="DD/MM/YYYY"
style={{ width: '100%' }}
placeholder={['Inicio', 'Fim']}
/>
</Form.Item>
</Form>
</Modal>
</>
);
}
// ─── PoliticasPage ─────────────────────────────────────────────────────────────
export function PoliticasPage() {
const items = [
{
key: 'descontos',
label: 'Desconto por Representante',
children: <TabDescontos />,
},
{
key: 'promocoes',
label: 'Promocoes',
children: <TabPromocoes />,
},
];
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Politicas Comerciais
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Limites de desconto e promocoes com vigencia
</Text>
</Flex>
<Card styles={{ body: { paddingTop: 0 } }}>
<Tabs items={items} />
</Card>
</Flex>
);
}

View File

@@ -1,5 +1,7 @@
import { useState } from 'react';
import { Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
const { useBreakpoint } = Grid;
import { EyeOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface';
@@ -94,6 +96,8 @@ export function CatalogPage() {
const [page, setPage] = useState(1);
const [selectedIdErp, setSelectedIdErp] = useState<number | null>(null);
const limit = 50;
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: pautas, isLoading: pautasLoading } = usePautas();
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
@@ -113,11 +117,11 @@ export function CatalogPage() {
</div>
{/* ── Filtros ──────────────────────────────────────────────────── */}
<Space style={{ marginBottom: 16 }} wrap>
<Space style={{ marginBottom: 16, width: '100%' }} wrap>
<Search
placeholder="Buscar por código ou descrição..."
allowClear
style={{ width: 300 }}
style={{ width: isMobile ? '100%' : 300 }}
onSearch={(v) => {
setQ(v);
setPage(1);
@@ -133,7 +137,7 @@ export function CatalogPage() {
placeholder="Selecionar pauta de preços"
allowClear
loading={pautasLoading}
style={{ width: 340 }}
style={{ width: isMobile ? '100%' : 340 }}
onChange={(v) => {
setIdPauta(v as number | undefined);
setPage(1);

View File

@@ -1,6 +1,7 @@
import {
Button,
Descriptions,
Grid,
Tag,
Table,
Typography,
@@ -14,6 +15,8 @@ import {
Badge,
} from 'antd';
import { useState } from 'react';
const { useBreakpoint } = Grid;
import type { TableColumnsType } from 'antd';
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
@@ -105,6 +108,8 @@ export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' });
const idNum = Number(id);
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const [addContactOpen, setAddContactOpen] = useState(false);
const [ordersModalOpen, setOrdersModalOpen] = useState(false);
const [produtosModalOpen, setProdutosModalOpen] = useState(false);
@@ -237,7 +242,7 @@ export function ClientDetailPage() {
</Button>
</Space>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions bordered size="small" column={isMobile ? 1 : 2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="Razão Social">{client.nome}</Descriptions.Item>
<Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item>
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
@@ -270,7 +275,14 @@ export function ClientDetailPage() {
{/* ── CTR + NF-e lado a lado ── */}
{msgCtx}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
gap: 16,
marginBottom: 24,
}}
>
{/* ── Contas a Receber ── */}
<div>
<div
@@ -425,7 +437,7 @@ export function ClientDetailPage() {
open={notasModalOpen}
onCancel={() => setNotasModalOpen(false)}
footer={null}
width={900}
width={isMobile ? '95vw' : 900}
centered
destroyOnHidden
>
@@ -491,7 +503,7 @@ export function ClientDetailPage() {
open={produtosModalOpen}
onCancel={() => setProdutosModalOpen(false)}
footer={null}
width={820}
width={isMobile ? '95vw' : 820}
centered
destroyOnHidden
>
@@ -554,7 +566,7 @@ export function ClientDetailPage() {
open={ordersModalOpen}
onCancel={() => setOrdersModalOpen(false)}
footer={null}
width={780}
width={isMobile ? '95vw' : 780}
centered
destroyOnHidden
>

View File

@@ -409,6 +409,8 @@ function CustomerDetailsModal({
onAnalyze: () => void;
}) {
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: detail, isLoading: loadingDetail } = useClientDetail(summary?.idCliente);
const { data: orders = [], isLoading: loadingOrders } = useClientOrders(summary?.idCliente);
@@ -451,7 +453,7 @@ function CustomerDetailsModal({
open={!!summary}
onCancel={onClose}
centered
width={860}
width={isMobile ? '95vw' : 860}
destroyOnHidden
styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
@@ -741,6 +743,8 @@ function CustomerAnalysisModal({
summary: ClientSummary | null;
onClose: () => void;
}) {
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: detail } = useClientDetail(summary?.idCliente);
if (!summary) return null;
@@ -771,7 +775,7 @@ function CustomerAnalysisModal({
open={!!summary}
onCancel={onClose}
centered
width={720}
width={isMobile ? '95vw' : 720}
destroyOnHidden
styles={{ body: { padding: '20px 24px' } }}
footer={

View File

@@ -61,16 +61,6 @@ const label: React.CSSProperties = {
marginBottom: 2,
};
function Field({ k, v }: { k: string; v: React.ReactNode }) {
if (v == null || v === '' || v === '—') return null;
return (
<div style={{ marginBottom: 5 }}>
<span style={label}>{k}</span>
<span style={{ fontSize: 11.5, color: INK }}>{v}</span>
</div>
);
}
export function OrderPrintPage() {
const { id } = useParams({ from: '/pedidos/$id/imprimir' });
const navigate = useNavigate();
@@ -213,57 +203,94 @@ export function OrderPrintPage() {
</div>
</div>
{/* ── Cliente + Representante ─────────────────────────────────────── */}
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ flex: 1.4, padding: '16px 28px', borderRight: `1px solid ${LINE}` }}>
{/* ── Representante + Cliente ─────────────────────────────────────── */}
<div
style={{
display: 'flex',
borderBottom: `1px solid ${LINE}`,
background: '#FAFBFD',
}}
>
{/* Representante */}
<div
style={{
flex: 1,
padding: '10px 20px 10px 28px',
borderRight: `1px solid ${LINE}`,
}}
>
<div
style={{
fontSize: 10,
fontSize: 8.5,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.1em',
marginBottom: 10,
letterSpacing: '0.12em',
textTransform: 'uppercase',
marginBottom: 4,
}}
>
CLIENTE
Representante
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 2 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: INK, lineHeight: 1.3 }}>
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
</div>
<div style={{ fontSize: 10, color: MUTED, marginTop: 2 }}>Cód. {order.codVendedor}</div>
</div>
{/* Cliente */}
<div style={{ flex: 2.2, padding: '10px 28px 10px 20px' }}>
<div
style={{
fontSize: 8.5,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.12em',
textTransform: 'uppercase',
marginBottom: 4,
}}
>
Cliente
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: INK, lineHeight: 1.3 }}>
{clienteNome}
</div>
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && (
<div style={{ fontSize: 11, color: MUTED, marginBottom: 8 }}>{tx(client?.nome)}</div>
<div style={{ fontSize: 10, color: MUTED, lineHeight: 1.3 }}>{tx(client?.nome)}</div>
)}
<div style={{ marginTop: 8 }}>
<Field k={docLabel} v={doc(client?.cgcpf)} />
<Field k="Inscr. Estadual" v={tx(client?.inscricaoEstadual)} />
<Field k="Endereço" v={enderecoCli} />
<Field k="Telefone" v={phone(client?.telefone, client?.ddd)} />
<Field k="E-mail" v={tx(client?.email)} />
</div>
</div>
<div style={{ flex: 1, padding: '16px 28px' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '2px 18px',
marginTop: 4,
fontSize: 10,
fontWeight: 800,
color: BLUE,
letterSpacing: '0.1em',
marginBottom: 10,
color: MUTED,
}}
>
REPRESENTANTE
{doc(client?.cgcpf) !== '—' && (
<span>
<strong style={{ color: INK }}>{docLabel}:</strong> {doc(client?.cgcpf)}
</span>
)}
{tx(client?.inscricaoEstadual) && (
<span>
<strong style={{ color: INK }}>IE:</strong> {tx(client?.inscricaoEstadual)}
</span>
)}
{enderecoCli && <span>{enderecoCli}</span>}
{phone(client?.telefone, client?.ddd) !== '—' && (
<span>
<strong style={{ color: INK }}>Tel:</strong>{' '}
{phone(client?.telefone, client?.ddd)}
</span>
)}
{tx(client?.email) && <span>{tx(client?.email)}</span>}
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 8 }}>
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
</div>
<Field k="Código" v={String(order.codVendedor)} />
<Field k="Data do pedido" v={dateBR(order.dtPedido)} />
<Field k="Nº do pedido" v={order.numPedSar} />
</div>
</div>
{/* ── Itens ───────────────────────────────────────────────────────── */}
<div style={{ padding: '8px 28px 0' }}>
<div style={{ padding: '0 28px 0' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead>
<tr style={{ background: '#F4F7FB' }}>

View File

@@ -77,6 +77,52 @@ function periodRange(p: string): { from?: string; to?: string } {
return {};
}
// ─── WhatsApp ─────────────────────────────────────────────────────────────────
function WhatsAppIcon({ size = 14 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
style={{ verticalAlign: '-2px' }}
>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
</svg>
);
}
function buildWhatsAppUrl(order: {
id: string;
numPedSar: string;
situa: number;
statusDescr?: string | null;
total: number | string;
dtPedido: string;
razaoCliente?: string | null;
nomeCliente?: string | null;
idCliente: number | string;
}): string {
const nome = order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`;
const status =
order.statusDescr ??
STATUS[order.situa]?.label ??
SITUA_LABEL[order.situa] ??
String(order.situa);
const pdfUrl = `${window.location.origin}/pedidos/${order.id}/imprimir`;
const text = [
`*Pedido ${order.numPedSar}*`,
`Cliente: ${nome}`,
`Data: ${fmtDate(order.dtPedido)}`,
`Total: ${fmt(order.total)}`,
`Status: ${status}`,
``,
`PDF: ${pdfUrl}`,
].join('\n');
return `https://wa.me/?text=${encodeURIComponent(text)}`;
}
// ─── Status Config ────────────────────────────────────────────────────────────
const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = {
@@ -226,6 +272,16 @@ function OrderActionsMenu({
label: 'Gerar PDF',
onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }),
},
{
key: 'whatsapp',
icon: (
<span style={{ color: '#25D366' }}>
<WhatsAppIcon />
</span>
),
label: <span style={{ color: '#25D366', fontWeight: 600 }}>Enviar pelo WhatsApp</span>,
onClick: () => window.open(buildWhatsAppUrl(order), '_blank'),
},
{
key: 'cancel',
icon: <CloseCircleOutlined />,
@@ -247,6 +303,8 @@ function OrderActionsMenu({
function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data, isLoading } = useOrderDetail(id ?? undefined);
const timelineItems = (data?.historico ?? []).map((h) => ({
@@ -291,7 +349,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
open={!!id}
onCancel={onClose}
centered
width={860}
width={isMobile ? '95vw' : 860}
destroyOnHidden
styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
@@ -299,6 +357,19 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
footer={
<Space>
<Button onClick={onClose}>Fechar</Button>
{data && (
<Button
icon={
<span style={{ color: '#25D366' }}>
<WhatsAppIcon />
</span>
}
style={{ color: '#25D366', borderColor: '#25D366' }}
onClick={() => window.open(buildWhatsAppUrl(data), '_blank')}
>
WhatsApp
</Button>
)}
{data && (
<Button
type="primary"
@@ -514,6 +585,18 @@ function MobileOrderCard({
>
Duplicar
</Button>
<Button
size="small"
icon={
<span style={{ color: '#25D366' }}>
<WhatsAppIcon />
</span>
}
style={{ borderRadius: 6 }}
onClick={() => window.open(buildWhatsAppUrl(order), '_blank')}
>
WhatsApp
</Button>
</div>
</Card>
);

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import {
Alert,
Badge,
Card,
Col,
@@ -40,7 +41,7 @@ function TabMeta() {
const [mes, setMes] = useState(now.month() + 1);
const [ano, setAno] = useState(now.year());
const { data, isLoading } = useReportMeta({ mes, ano });
const { data, isLoading, isError, error } = useReportMeta({ mes, ano });
const realizado = Number(data?.realizado ?? 0);
const meta = Number(data?.meta ?? 0);
@@ -88,8 +89,15 @@ function TabMeta() {
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
) : isError ? (
<Alert
type="error"
showIcon
message="Erro ao carregar desempenho"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
/>
) : (
<Space direction="vertical" size={20} style={{ width: '100%' }}>
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: '24px 28px' } }}
@@ -138,28 +146,30 @@ function TabMeta() {
<Statistic
title="Pedidos Transmitidos"
value={data?.totalPedidos ?? 0}
valueStyle={{ color: '#003B8E' }}
styles={{ content: { color: '#003B8E' } }}
/>
</Col>
<Col span={12}>
<Statistic
title="Comissão Estimada"
value={fmt(comissao)}
valueStyle={{ color: '#52c41a', fontSize: 20 }}
styles={{ content: { color: '#52c41a', fontSize: 20 } }}
/>
</Col>
<Col span={12}>
<Statistic
title="Falta para Meta"
value={meta > realizado ? fmt(meta - realizado) : 'Atingida'}
valueStyle={{ color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 }}
styles={{
content: { color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 },
}}
/>
</Col>
<Col span={12}>
<Statistic
title="% Atingido"
value={`${percentual.toFixed(1)}%`}
valueStyle={{ color: progressColor, fontSize: 18 }}
styles={{ content: { color: progressColor, fontSize: 18 } }}
/>
</Col>
</Row>
@@ -192,7 +202,7 @@ type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
function TabCarteira() {
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
const { data, isLoading } = useReportCarteira();
const { data, isLoading, isError, error } = useReportCarteira();
const clientes = data?.data ?? [];
@@ -230,7 +240,7 @@ function TabCarteira() {
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => (
<Space direction="vertical" size={0}>
<Space orientation="vertical" size={0}>
<Text strong style={{ fontSize: 13 }}>
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
</Text>
@@ -294,6 +304,17 @@ function TabCarteira() {
},
];
if (isError) {
return (
<Alert
type="error"
showIcon
message="Erro ao carregar carteira de clientes"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
/>
);
}
return (
<div>
{data && (
@@ -318,7 +339,7 @@ function TabCarteira() {
<Statistic
title={item.label}
value={item.value}
valueStyle={{ color: item.color, fontSize: 24 }}
styles={{ content: { color: item.color, fontSize: 24 } }}
/>
</Card>
</Col>
@@ -354,7 +375,7 @@ function TabAbc() {
const [mes, setMes] = useState<number | undefined>(now.month() + 1);
const [ano, setAno] = useState<number | undefined>(now.year());
const { data, isLoading } = useReportAbc({ mes, ano });
const { data, isLoading, isError, error } = useReportAbc({ mes, ano });
const meses = [
'Jan',
@@ -390,7 +411,7 @@ function TabAbc() {
title: 'Produto',
key: 'produto',
render: (_: unknown, p: AbcProduto) => (
<Space direction="vertical" size={0}>
<Space orientation="vertical" size={0}>
{p.codProduto && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{p.codProduto}</Text>}
<Text style={{ fontSize: 13, fontWeight: 500 }}>{p.descProduto}</Text>
</Space>
@@ -402,7 +423,7 @@ function TabAbc() {
width: 150,
align: 'right' as const,
render: (v: string, p: AbcProduto) => (
<Space direction="vertical" size={0} style={{ alignItems: 'flex-end' }}>
<Space orientation="vertical" size={0} style={{ alignItems: 'flex-end' }}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
@@ -447,6 +468,17 @@ function TabAbc() {
},
];
if (isError) {
return (
<Alert
type="error"
showIcon
message="Erro ao carregar curva ABC"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
/>
);
}
return (
<div>
<Space style={{ marginBottom: 20 }}>

View File

@@ -1,25 +1,25 @@
import { useState, type ReactNode } from 'react';
import { Alert, Button, Flex, Tooltip } from 'antd';
import { type ReactNode } from 'react';
import { Alert, Button, Flex, Grid, Tooltip } from 'antd';
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import { Topbar } from './Topbar';
import { Sidebar } from './Sidebar';
import { BottomNav } from './BottomNav';
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
const { useBreakpoint } = Grid;
interface AppShellProps {
children: ReactNode;
}
/**
* AppShell canônico do SAR — topbar 80 + sidebar 260 + área de conteúdo.
* Layout para cockpits Sandra/Daniel/Alice (desktop-first).
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
*/
export function AppShell({ children }: AppShellProps) {
const [, setSidebarOpen] = useState(true);
const navigate = useNavigate();
const isOnline = useNetworkStatus();
const screens = useBreakpoint();
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
const isDesktop = !!screens.lg;
useOfflineSync();
return (
@@ -34,13 +34,16 @@ export function AppShell({ children }: AppShellProps) {
style={{ padding: '6px 16px', fontSize: 13 }}
/>
)}
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
<Topbar />
<Flex flex={1} style={{ overflow: 'hidden' }}>
<Sidebar />
{isDesktop && <Sidebar />}
<main
style={{
flex: 1,
padding: 'var(--space-xl)',
padding: isDesktop ? 'var(--space-xl)' : 'var(--space-md)',
paddingBottom: isDesktop
? 'var(--space-xl)'
: 'calc(var(--layout-bottom-nav-height) + var(--space-lg))',
overflowY: 'auto',
overflowX: 'hidden',
minWidth: 0,
@@ -50,7 +53,9 @@ export function AppShell({ children }: AppShellProps) {
</main>
</Flex>
{/* FAB — Novo Pedido */}
{!isDesktop && <BottomNav />}
{/* FAB — Novo Pedido: acima do bottom nav em mobile */}
<Tooltip title="Novo Pedido" placement="left">
<Button
type="primary"
@@ -59,8 +64,8 @@ export function AppShell({ children }: AppShellProps) {
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{
position: 'fixed',
bottom: 32,
right: 32,
bottom: isDesktop ? 32 : 'calc(var(--layout-bottom-nav-height) + 16px)',
right: 20,
width: 52,
height: 52,
fontSize: 22,

View File

@@ -0,0 +1,119 @@
import { useLocation, useNavigate } from '@tanstack/react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faChartLine,
faClipboardList,
faClipboardCheck,
faUsers,
faAddressCard,
faBoxesStacked,
faFileInvoiceDollar,
faTags,
} from '@fortawesome/free-solid-svg-icons';
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { authStore } from '../../lib/auth-store';
interface NavItem {
key: string;
icon: IconDefinition;
label: string;
}
function getRole(): 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';
}
}
const REP_ITEMS: NavItem[] = [
{ key: '/', icon: faChartLine, label: 'Painel' },
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
{ key: '/catalogo', icon: faBoxesStacked, label: 'Catálogo' },
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
];
const SUPERVISOR_ITEMS: NavItem[] = [
{ key: '/', icon: faChartLine, label: 'Painel' },
{ key: '/aprovacoes', icon: faClipboardCheck, label: 'Aprovações' },
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
];
const GERENTE_ITEMS: NavItem[] = [
{ key: '/ger', icon: faChartLine, label: 'Painel' },
{ key: '/ger/equipe', icon: faUsers, label: 'Equipe' },
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
{ key: '/ger/politicas', icon: faTags, label: 'Políticas' },
];
function getItems(role: string): NavItem[] {
if (role === 'manager' || role === 'admin') return GERENTE_ITEMS;
if (role === 'supervisor') return SUPERVISOR_ITEMS;
return REP_ITEMS;
}
export function BottomNav() {
const navigate = useNavigate();
const location = useLocation();
const role = getRole();
const items = getItems(role);
return (
<nav
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 'var(--layout-bottom-nav-height)',
background: 'var(--bg-surface)',
borderTop: '1px solid var(--border-subtle)',
display: 'flex',
zIndex: 'var(--z-fixed)' as unknown as number,
boxShadow: '0 -2px 12px rgba(0,0,0,0.06)',
}}
>
{items.map((item) => {
const isActive =
item.key === '/' || item.key === '/ger'
? location.pathname === item.key
: location.pathname.startsWith(item.key);
return (
<button
key={item.key}
onClick={() => void navigate({ to: item.key })}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
border: 'none',
background: 'none',
cursor: 'pointer',
padding: '6px 4px',
color: isActive ? 'var(--jcs-blue)' : 'var(--text-muted)',
fontSize: 10,
fontWeight: isActive ? 600 : 400,
fontFamily: 'var(--font-family-base)',
transition: 'color var(--duration-fast) var(--easing-standard)',
WebkitTapHighlightColor: 'transparent',
}}
>
<FontAwesomeIcon icon={item.icon} style={{ fontSize: 20 }} />
{item.label}
</button>
);
})}
</nav>
);
}

View File

@@ -7,55 +7,116 @@ import {
faClipboardCheck,
faFunnelDollar,
faUsers,
faAddressCard,
faBoxesStacked,
faFileInvoiceDollar,
faTags,
} from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface';
import { authStore } from '../../lib/auth-store';
function getRole(): 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';
}
}
const REP_ITEMS: ItemType[] = [
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
{
key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},
{
key: '/funil',
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
label: 'Funil de Vendas',
},
{
key: '/pedidos',
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
label: 'Pedidos',
},
{
key: '/pedidos-erp',
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Consulta ERP',
},
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
label: 'Relatórios',
},
];
const SUPERVISOR_ITEMS: ItemType[] = [
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
{
key: '/aprovacoes',
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Aprovações',
},
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
{
key: '/pedidos',
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
label: 'Pedidos',
},
{
key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
label: 'Relatórios',
},
];
const GERENTE_ITEMS: ItemType[] = [
{ key: '/ger', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
{ key: '/ger/equipe', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Equipe' },
{
key: '/clientes',
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
label: 'Clientes',
},
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
label: 'Relatórios',
},
{
key: '/ger/politicas',
icon: <FontAwesomeIcon icon={faTags} fixedWidth />,
label: 'Políticas Comerciais',
},
];
function getItems(role: string): ItemType[] {
if (role === 'manager' || role === 'admin') return GERENTE_ITEMS;
if (role === 'supervisor') return SUPERVISOR_ITEMS;
return REP_ITEMS;
}
/**
* Sidebar canônica do SAR (260px fixa — brand.md).
* Itens mockados para Rafael cockpit. Variantes por cockpit virão depois.
*/
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const items: ItemType[] = [
{
key: '/',
icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />,
label: 'Painel',
},
{
key: '/clientes',
icon: <FontAwesomeIcon icon={faUsers} fixedWidth />,
label: 'Clientes',
},
{
key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo',
},
{
key: '/funil',
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
label: 'Funil de Vendas',
},
{
key: '/pedidos',
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
label: 'Pedidos',
},
{
key: '/pedidos-erp',
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Consulta ERP',
},
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
label: 'Relatórios',
},
];
const role = getRole();
const items = getItems(role);
const selectedKey = items.find((item) => {
const key = (item as { key?: string }).key ?? '';
return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
})?.key as string | undefined;
return (
<aside
@@ -73,7 +134,7 @@ export function Sidebar() {
>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
selectedKeys={selectedKey ? [selectedKey] : [location.pathname]}
items={items}
onClick={({ key }) => navigate({ to: key as string })}
style={{

View File

@@ -1,7 +1,7 @@
import { Avatar, Badge, Button, Dropdown, Flex, Typography } from 'antd';
import { Avatar, Badge, Button, Dropdown, Flex, Grid, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBell, faBars, faBuilding, faRightFromBracket } from '@fortawesome/free-solid-svg-icons';
import { faBell, faBuilding, faRightFromBracket } from '@fortawesome/free-solid-svg-icons';
import { useNavigate } from '@tanstack/react-router';
import { brandTokens } from '../../lib/theme';
import { FoundationStatus } from './FoundationStatus';
@@ -10,22 +10,17 @@ import { useCurrentUser } from '../../lib/queries/auth';
import { useCompany } from '../../lib/queries/company';
import { authStore } from '../../lib/auth-store';
interface TopbarProps {
onToggleSidebar?: () => void;
}
const { useBreakpoint } = Grid;
/**
* Topbar canônica do SAR (80px de altura — brand.md).
* Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita.
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
*/
function logout() {
authStore.clear();
window.location.reload();
}
export function Topbar({ onToggleSidebar }: TopbarProps) {
export function Topbar() {
const navigate = useNavigate();
const screens = useBreakpoint();
const isDesktop = !!screens.lg;
const { data: pendingData } = usePendingCount();
const pendingCount = pendingData?.count ?? 0;
const { data: user } = useCurrentUser();
@@ -76,26 +71,20 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
align="center"
justify="space-between"
style={{
height: 'var(--layout-topbar-height)',
padding: '0 var(--space-lg)',
height: isDesktop ? 'var(--layout-topbar-height)' : 56,
padding: isDesktop ? '0 var(--space-lg)' : '0 var(--space-md)',
background: 'var(--bg-surface)',
borderBottom: '1px solid var(--border-subtle)',
position: 'sticky',
top: 0,
zIndex: 'var(--z-sticky)',
flexShrink: 0,
}}
>
{/* Lado esquerdo: hamburger (mobile) + logo */}
<Flex align="center" gap={16}>
<Button
type="text"
icon={<FontAwesomeIcon icon={faBars} />}
onClick={onToggleSidebar}
aria-label="Alternar menu"
style={{ display: 'inline-flex' }}
/>
<Flex align="center" gap={12}>
<img src="/sar-icon.png" alt="SAR" style={{ height: 40, width: 'auto' }} />
{/* Logo */}
<Flex align="center" gap={isDesktop ? 12 : 8}>
<img src="/sar-icon.png" alt="SAR" style={{ height: isDesktop ? 40 : 32, width: 'auto' }} />
{isDesktop && (
<Flex vertical gap={0}>
<span
style={{
@@ -119,29 +108,36 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
Força de Vendas
</span>
</Flex>
</Flex>
)}
</Flex>
{/* Centro: empresa ativa */}
<Flex flex={1} justify="center" style={{ margin: '0 var(--space-2xl)' }}>
<Flex align="center" gap={10}>
<Flex
flex={1}
justify="center"
style={{
margin: `0 ${isDesktop ? 'var(--space-2xl)' : 'var(--space-sm)'}`,
overflow: 'hidden',
}}
>
<Flex align="center" gap={isDesktop ? 10 : 6} style={{ overflow: 'hidden' }}>
<FontAwesomeIcon
icon={faBuilding}
style={{ color: 'var(--text-muted)', fontSize: 16, flexShrink: 0 }}
style={{ color: 'var(--text-muted)', fontSize: isDesktop ? 16 : 13, flexShrink: 0 }}
/>
<Flex vertical gap={0}>
<Flex vertical gap={0} style={{ overflow: 'hidden' }}>
<Typography.Text
ellipsis
style={{
fontSize: 'var(--text-xl)',
fontSize: isDesktop ? 'var(--text-xl)' : 'var(--text-sm)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--text-main)',
lineHeight: 1.2,
whiteSpace: 'nowrap',
}}
>
{nomeEmpresa}
</Typography.Text>
{mostrarRazao && (
{isDesktop && mostrarRazao && (
<Typography.Text
style={{
fontSize: 'var(--text-2xs)',
@@ -157,33 +153,36 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
</Flex>
</Flex>
{/* Lado direito: novo pedido + status fundação + notificações + perfil */}
<Flex align="center" gap={16}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{ borderRadius: 8, fontWeight: 'var(--font-weight-semibold)' }}
>
Novo Pedido
</Button>
<FoundationStatus />
{/* Lado direito */}
<Flex align="center" gap={isDesktop ? 16 : 8}>
{isDesktop && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{ borderRadius: 8, fontWeight: 'var(--font-weight-semibold)' }}
>
Novo Pedido
</Button>
)}
{isDesktop && <FoundationStatus />}
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
<Button
type="text"
size="large"
size={isDesktop ? 'large' : 'middle'}
icon={<FontAwesomeIcon icon={faBell} />}
aria-label="Notificações"
/>
</Badge>
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
<Avatar
size={40}
size={isDesktop ? 40 : 32}
style={{
background: 'var(--jcs-blue-light)',
color: 'var(--jcs-blue)',
fontWeight: 'var(--font-weight-semibold)',
cursor: 'pointer',
flexShrink: 0,
}}
>
{initials}

View File

@@ -0,0 +1,108 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ManagerDashboardSchema,
EquipeResponseSchema,
AlcadaDescontosResponseSchema,
PromocoesResponseSchema,
type ManagerDashboard,
type EquipeResponse,
type AlcadaDescontosResponse,
type PromocoesResponse,
type UpsertDescontoBody,
type CreatePromocaoBody,
type UpdatePromocaoBody,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useManagerDashboard(mes?: number, ano?: number) {
const params = new URLSearchParams();
if (mes) params.set('mes', String(mes));
if (ano) params.set('ano', String(ano));
const qs = params.size > 0 ? `?${params.toString()}` : '';
return useQuery<ManagerDashboard>({
queryKey: ['dashboard', 'manager', mes, ano],
queryFn: async () => {
const raw = await apiFetch(`/dashboard/manager${qs}`);
return ManagerDashboardSchema.parse(raw);
},
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
}
export function useEquipe() {
return useQuery<EquipeResponse>({
queryKey: ['equipe'],
queryFn: async () => {
const raw = await apiFetch('/equipe');
return EquipeResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}
export function usePoliticasDescontos() {
return useQuery<AlcadaDescontosResponse>({
queryKey: ['politicas', 'descontos'],
queryFn: async () => {
const raw = await apiFetch('/politicas/descontos');
return AlcadaDescontosResponseSchema.parse(raw);
},
staleTime: 10 * 60 * 1000,
});
}
export function usePromocoes() {
return useQuery<PromocoesResponse>({
queryKey: ['politicas', 'promocoes'],
queryFn: async () => {
const raw = await apiFetch('/politicas/promocoes');
return PromocoesResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}
export function useUpsertDesconto() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: UpsertDescontoBody) =>
apiFetch('/politicas/descontos', { method: 'POST', body: JSON.stringify(body) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
});
}
export function useCreatePromocao() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreatePromocaoBody) =>
apiFetch('/politicas/promocoes', { method: 'POST', body: JSON.stringify(body) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
},
});
}
export function useUpdatePromocao() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) =>
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
},
});
}
export function useDeletePromocao() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiFetch(`/politicas/promocoes/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
},
});
}

View File

@@ -20,6 +20,9 @@ import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { GerPainel } from '../cockpits/ger/GerPainel';
import { EquipePage } from '../cockpits/ger/EquipePage';
import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
import { authStore } from './auth-store';
function getRoleFromToken(): string {
@@ -35,7 +38,9 @@ function getRoleFromToken(): string {
function HomeRoute() {
const role = getRoleFromToken();
return role === 'supervisor' || role === 'manager' ? <SupervisorPainel /> : <RepPainel />;
if (role === 'manager' || role === 'admin') return <GerPainel />;
if (role === 'supervisor') return <SupervisorPainel />;
return <RepPainel />;
}
function NotFoundPage() {
@@ -138,6 +143,24 @@ const aprovacoes = createRoute({
component: ApprovalQueuePage,
});
const gerPainelRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger',
component: GerPainel,
});
const equipeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/equipe',
component: EquipePage,
});
const politicasRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/politicas',
component: PoliticasPage,
});
const routeTree = rootRoute.addChildren([
indexRoute,
rafaelRoute,
@@ -152,6 +175,9 @@ const routeTree = rootRoute.addChildren([
pedidosErpRoute,
relatoriosRoute,
aprovacoes,
gerPainelRoute,
equipeRoute,
politicasRoute,
]);
export const router = createRouter({