feat(web+api): ficha do cliente — contatos, CTR, NF-e, pedidos e produtos
- Contatos: grid de cards 3 colunas, botão "Novo Contato" no topo da página - Sync ERP↔SAR corrigido: vw_contatos aceita formato COR#<id>, trigger normaliza id_empresa (9001→1) - CTR + NF-e: layout 50/50 — lista de títulos abertos com badge vencido/a vencer e lista de notas com botão copiar chave NF-e - Histórico de pedidos: UNION SAR+ERP, top 5 + modal "Ver todos" - Produtos mais comprados: top 5 com último preço + modal "Ver todos" - Novos endpoints: ctr-list, notas, orders-history, top-produtos - AppShell: overflow-x travado, sem scroll horizontal na aplicação Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { OrdersModule } from './orders/orders.module';
|
|||||||
import { CatalogModule } from './catalog/catalog.module';
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
import { DashboardModule } from './dashboard/dashboard.module';
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
|
import { ReportsModule } from './reports/reports.module';
|
||||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -29,6 +30,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
|||||||
CatalogModule,
|
CatalogModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
ReportsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ import {
|
|||||||
type ClientNovoResult,
|
type ClientNovoResult,
|
||||||
type Contato,
|
type Contato,
|
||||||
type ContatoResult,
|
type ContatoResult,
|
||||||
|
type CtrSummary,
|
||||||
type CreateClientNovo,
|
type CreateClientNovo,
|
||||||
type CreateContato,
|
type CreateContato,
|
||||||
|
type CtrTitulo,
|
||||||
|
type NotaFiscal,
|
||||||
|
type PedidoSummary,
|
||||||
|
type TopProduto,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { ClientsService } from './clients.service';
|
import { ClientsService } from './clients.service';
|
||||||
|
|
||||||
@@ -35,6 +40,47 @@ export class ClientsController {
|
|||||||
return this.clients.createNovo(parsed);
|
return this.clients.createNovo(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/notas')
|
||||||
|
listNotasFiscais(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<NotaFiscal[]> {
|
||||||
|
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
|
||||||
|
return this.clients.listNotasFiscais(id, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/top-produtos')
|
||||||
|
listTopProdutos(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<TopProduto[]> {
|
||||||
|
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
|
||||||
|
return this.clients.listTopProdutos(id, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/orders-history')
|
||||||
|
listOrdersHistory(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<PedidoSummary[]> {
|
||||||
|
const n = limit ? Math.min(parseInt(limit, 10) || 50, 200) : 50;
|
||||||
|
return this.clients.listOrdersHistory(id, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/ctr')
|
||||||
|
getCtrSummary(@Param('id', ParseIntPipe) id: number): Promise<CtrSummary> {
|
||||||
|
return this.clients.getCtrSummary(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/ctr-list')
|
||||||
|
listCtrTitulos(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<CtrTitulo[]> {
|
||||||
|
const n = limit ? Math.min(parseInt(limit, 10) || 60, 200) : 60;
|
||||||
|
return this.clients.listCtrTitulos(id, n);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/contacts')
|
@Get(':id/contacts')
|
||||||
listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> {
|
listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> {
|
||||||
return this.clients.listContacts(id);
|
return this.clients.listContacts(id);
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ import type {
|
|||||||
ClientSummary,
|
ClientSummary,
|
||||||
Contato,
|
Contato,
|
||||||
ContatoResult,
|
ContatoResult,
|
||||||
|
CtrSummary,
|
||||||
|
CtrTitulo,
|
||||||
CreateClientNovo,
|
CreateClientNovo,
|
||||||
CreateContato,
|
CreateContato,
|
||||||
|
NotaFiscal,
|
||||||
|
PedidoSummary,
|
||||||
|
TopProduto,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
@@ -364,6 +369,161 @@ export class ClientsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
num_ped: string;
|
||||||
|
situa: number;
|
||||||
|
dt_pedido: Date;
|
||||||
|
total: string;
|
||||||
|
fonte: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT id::text AS id,
|
||||||
|
num_ped_sar AS num_ped,
|
||||||
|
situa,
|
||||||
|
dt_pedido,
|
||||||
|
total::text,
|
||||||
|
'sar' AS fonte
|
||||||
|
FROM sar.pedidos
|
||||||
|
WHERE id_cliente = ${idCliente}
|
||||||
|
AND id_empresa = ${idEmpresa}
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT ('erp-' || id_pedido::text) AS id,
|
||||||
|
COALESCE(NULLIF(TRIM(num_ped_sar::text),''), numero::text) AS num_ped,
|
||||||
|
CASE WHEN situa = 5 THEN 3 ELSE situa END AS situa,
|
||||||
|
dt_pedido,
|
||||||
|
total::text,
|
||||||
|
'erp' AS fonte
|
||||||
|
FROM sar.vw_pedidos_erp
|
||||||
|
WHERE id_cliente = ${idCliente}
|
||||||
|
AND id_empresa = ${idEmpresa}
|
||||||
|
AND situa != 5
|
||||||
|
|
||||||
|
ORDER BY dt_pedido DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
numPedSar: r.num_ped ?? r.id,
|
||||||
|
idCliente,
|
||||||
|
nomeCliente: null,
|
||||||
|
razaoCliente: null,
|
||||||
|
codVendedor: 0,
|
||||||
|
nomeVendedor: null,
|
||||||
|
situa: Number(r.situa),
|
||||||
|
dtPedido: r.dt_pedido.toISOString(),
|
||||||
|
total: r.total ?? '0',
|
||||||
|
descontoPerc: '0',
|
||||||
|
obs: null,
|
||||||
|
createdAt: r.dt_pedido.toISOString(),
|
||||||
|
fonte: r.fonte as 'sar' | 'erp',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
|
||||||
|
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id_produto: number;
|
||||||
|
descricao: string;
|
||||||
|
unidade: string | null;
|
||||||
|
qtd_total: string;
|
||||||
|
num_pedidos: string;
|
||||||
|
ultima_compra: Date;
|
||||||
|
ultimo_preco: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT
|
||||||
|
i.produ AS id_produto,
|
||||||
|
TRIM(p.descricao) AS descricao,
|
||||||
|
TRIM(p.unidade) AS unidade,
|
||||||
|
SUM(i.qtd)::numeric(15,3)::text AS qtd_total,
|
||||||
|
COUNT(DISTINCT ped.id_pedido)::text AS num_pedidos,
|
||||||
|
MAX(ped.data) AS ultima_compra,
|
||||||
|
(
|
||||||
|
SELECT i2.pruni::numeric(15,2)::text
|
||||||
|
FROM sig.peditens i2
|
||||||
|
JOIN sig.pedidos p2 ON p2.id_pedido = i2.id_pedido
|
||||||
|
WHERE i2.produ = i.produ
|
||||||
|
AND p2.clien = ped.clien
|
||||||
|
AND p2.id_empresa = ${idEmpresa}
|
||||||
|
AND p2.situa NOT IN (5)
|
||||||
|
ORDER BY p2.data DESC, p2.id_pedido DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS ultimo_preco
|
||||||
|
FROM sig.pedidos ped
|
||||||
|
JOIN sig.peditens i ON i.id_pedido = ped.id_pedido
|
||||||
|
JOIN gestao.produto p ON p.id_erp = i.produ
|
||||||
|
AND p.id_empresa = ${idEmpresaMatriz}
|
||||||
|
WHERE ped.clien = ${idCliente}
|
||||||
|
AND ped.id_empresa = ${idEmpresa}
|
||||||
|
AND ped.situa NOT IN (5)
|
||||||
|
GROUP BY i.produ, p.descricao, p.unidade, ped.clien
|
||||||
|
ORDER BY SUM(i.qtd) DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
idProduto: Number(r.id_produto),
|
||||||
|
descricao: r.descricao,
|
||||||
|
unidade: r.unidade,
|
||||||
|
qtdTotal: parseFloat(r.qtd_total ?? '0'),
|
||||||
|
numPedidos: parseInt(r.num_pedidos ?? '0', 10),
|
||||||
|
ultimaCompra: (r.ultima_compra as Date).toISOString().slice(0, 10),
|
||||||
|
ultimoPreco: parseFloat(r.ultimo_preco ?? '0'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
qtd_aberto: string;
|
||||||
|
total_aberto: string;
|
||||||
|
qtd_vencido: string;
|
||||||
|
total_vencido: string;
|
||||||
|
maior_atraso_dias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::text AS qtd_aberto,
|
||||||
|
COALESCE(SUM(saldo), 0)::numeric(15,2)::text AS total_aberto,
|
||||||
|
(COUNT(*) FILTER (WHERE dt_vencimento < CURRENT_DATE))::text AS qtd_vencido,
|
||||||
|
COALESCE(SUM(saldo) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::numeric(15,2)::text AS total_vencido,
|
||||||
|
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
|
||||||
|
FROM sar.vw_ctr
|
||||||
|
WHERE id_cliente = ${idCliente}
|
||||||
|
AND situacao = 'A'
|
||||||
|
AND saldo > 0
|
||||||
|
`);
|
||||||
|
|
||||||
|
const r = rows[0];
|
||||||
|
return {
|
||||||
|
qtdAberto: parseInt(r?.qtd_aberto ?? '0', 10),
|
||||||
|
totalAberto: parseFloat(r?.total_aberto ?? '0'),
|
||||||
|
qtdVencido: parseInt(r?.qtd_vencido ?? '0', 10),
|
||||||
|
totalVencido: parseFloat(r?.total_vencido ?? '0'),
|
||||||
|
maiorAtrasoDias: parseInt(r?.maior_atraso_dias ?? '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async findOne(idCliente: number): Promise<ClientDetail> {
|
async findOne(idCliente: number): Promise<ClientDetail> {
|
||||||
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');
|
||||||
@@ -388,6 +548,7 @@ export class ClientsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
idCliente: Number(r.id_cliente),
|
idCliente: Number(r.id_cliente),
|
||||||
|
|
||||||
idEmpresa: Number(r.id_empresa),
|
idEmpresa: Number(r.id_empresa),
|
||||||
nome: r.nome,
|
nome: r.nome,
|
||||||
razao: r.razao,
|
razao: r.razao,
|
||||||
@@ -413,4 +574,106 @@ export class ClientsService {
|
|||||||
dtAtual: r.dt_atual,
|
dtAtual: r.dt_atual,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
type CtrRow = {
|
||||||
|
id_ctr: number;
|
||||||
|
numero: string;
|
||||||
|
prefixo: string;
|
||||||
|
dt_emissao: Date;
|
||||||
|
dt_vencimento: Date;
|
||||||
|
saldo: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<CtrRow[]>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id_ctr,
|
||||||
|
TRIM(numero) AS numero,
|
||||||
|
TRIM(prefixo) AS prefixo,
|
||||||
|
dt_emissao,
|
||||||
|
dt_vencimento,
|
||||||
|
saldo::text AS saldo
|
||||||
|
FROM sar.vw_ctr
|
||||||
|
WHERE id_cliente = $1
|
||||||
|
AND id_empresa = 1
|
||||||
|
AND situacao = 'A'
|
||||||
|
AND saldo > 0
|
||||||
|
ORDER BY dt_vencimento ASC
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
idCliente,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toDate = (d: Date | string): string =>
|
||||||
|
d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10);
|
||||||
|
|
||||||
|
return rows.map((r: CtrRow) => ({
|
||||||
|
idCtr: Number(r.id_ctr),
|
||||||
|
numero: r.numero,
|
||||||
|
prefixo: r.prefixo,
|
||||||
|
dtEmissao: toDate(r.dt_emissao),
|
||||||
|
dtVencimento: toDate(r.dt_vencimento),
|
||||||
|
saldo: Number(r.saldo),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
type NfRow = {
|
||||||
|
id_nf: number;
|
||||||
|
numero: number;
|
||||||
|
serie: string;
|
||||||
|
dt_emissao: Date;
|
||||||
|
vl_nf: string;
|
||||||
|
chave_acesso_nfe: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<NfRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id_nf, numero, serie, dt_emissao, vl_nf, chave_acesso_nfe
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (nf.id_nf)
|
||||||
|
nf.id_nf,
|
||||||
|
nf.numero,
|
||||||
|
TRIM(nf.serie) AS serie,
|
||||||
|
nf.dt_emissao,
|
||||||
|
nf.vl_nf::text AS vl_nf,
|
||||||
|
TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe
|
||||||
|
FROM gestao.nf nf
|
||||||
|
JOIN sig.pedidos p
|
||||||
|
ON p.numero = nf.num_entrega
|
||||||
|
AND p.tipo = 'E'
|
||||||
|
AND p.id_empresa IN (1, 9001)
|
||||||
|
WHERE p.clien = $1
|
||||||
|
AND nf.id_empresa = 1
|
||||||
|
AND nf.status = 'E'
|
||||||
|
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
|
||||||
|
ORDER BY nf.id_nf
|
||||||
|
) sub
|
||||||
|
ORDER BY dt_emissao DESC
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
idCliente,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r: NfRow) => ({
|
||||||
|
idNf: Number(r.id_nf),
|
||||||
|
numero: Number(r.numero),
|
||||||
|
serie: r.serie?.trim() ?? '',
|
||||||
|
dtEmissao:
|
||||||
|
(r.dt_emissao instanceof Date
|
||||||
|
? r.dt_emissao.toISOString().split('T')[0]
|
||||||
|
: String(r.dt_emissao)) ?? '',
|
||||||
|
vlNf: Number(r.vl_nf),
|
||||||
|
chaveAcessoNfe: r.chave_acesso_nfe?.trim() || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,16 +35,14 @@ interface RealizadoGrupoRow {
|
|||||||
peso: string;
|
peso: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RepRow {
|
interface NaoPositivadoRow {
|
||||||
taxa_com: string;
|
|
||||||
permitir_flex: number; // 0 ou 1 (char do ERP convertido)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InativoRow {
|
|
||||||
id_cliente: number;
|
id_cliente: number;
|
||||||
nome: string;
|
nome: string;
|
||||||
dt_ultima_compra: Date | null;
|
razao: string | null;
|
||||||
ultima_compra_valor: string | null;
|
dt_ultimo_pedido: string | null; // YYYY-MM-DD ou null
|
||||||
|
dias_sem_pedido: number | null;
|
||||||
|
comprou_antes: boolean;
|
||||||
|
whatsapp: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InativosPorRepRow {
|
interface InativosPorRepRow {
|
||||||
@@ -92,24 +90,7 @@ export class DashboardService {
|
|||||||
: grRows.reduce((a, m) => a + Number(m.valor), 0);
|
: grRows.reduce((a, m) => a + Number(m.valor), 0);
|
||||||
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
|
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
|
||||||
|
|
||||||
// 2. Taxas do representante — fonte: gestao.vendedor (via vw_representantes)
|
// 2. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
|
||||||
const repRows = await prisma.$queryRawUnsafe<RepRow[]>(`
|
|
||||||
SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex
|
|
||||||
FROM vw_representantes
|
|
||||||
WHERE codigo = ${codVendedor}
|
|
||||||
LIMIT 1
|
|
||||||
`);
|
|
||||||
const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3;
|
|
||||||
const permitirFlex = (repRows[0]?.permitir_flex ?? 0) === 1;
|
|
||||||
|
|
||||||
// 3. Taxa flex — fonte: sar.meta_representante (override SAR; default 1%)
|
|
||||||
const flexOverride = await prisma.metaRepresentante.findUnique({
|
|
||||||
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano: year, mes: month } },
|
|
||||||
select: { taxaFlex: true },
|
|
||||||
});
|
|
||||||
const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1;
|
|
||||||
|
|
||||||
// 4. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
|
|
||||||
// Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido.
|
// Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido.
|
||||||
const monthStartStr = monthStart.toISOString().slice(0, 10);
|
const monthStartStr = monthStart.toISOString().slice(0, 10);
|
||||||
const monthEndStr = monthEnd.toISOString().slice(0, 10);
|
const monthEndStr = monthEnd.toISOString().slice(0, 10);
|
||||||
@@ -198,32 +179,55 @@ export class DashboardService {
|
|||||||
const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0;
|
const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0;
|
||||||
const falta = Math.max(0, targetAmount - atingido);
|
const falta = Math.max(0, targetAmount - atingido);
|
||||||
|
|
||||||
const fixa = Math.round(atingido * commissionRate) / 100;
|
// Clientes não positivados no mês: ativos do representante sem pedido desde monthStart.
|
||||||
const flex =
|
// Junta o último pedido histórico (ERP+SAR) e o primeiro whatsapp ativo dos contatos.
|
||||||
permitirFlex && targetAmount > 0 && atingido >= targetAmount
|
const naoPositivadosRows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
|
||||||
? Math.round(atingido * flexRate) / 100
|
WITH ultimo_pedido AS (
|
||||||
: 0;
|
SELECT id_cliente,
|
||||||
|
MAX(dt_pedido) AS dt_max
|
||||||
// 7. Clientes inativos — sem pedido no ERP há >30 dias
|
FROM (
|
||||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
SELECT id_cliente, dt_pedido FROM vw_pedidos_erp
|
||||||
const inactiveClients = await prisma.$queryRawUnsafe<InativoRow[]>(`
|
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
|
||||||
|
UNION ALL
|
||||||
|
SELECT id_cliente, dt_pedido FROM sar.pedidos
|
||||||
|
WHERE situa != 3 AND id_empresa = ${idEmpresa}
|
||||||
|
) t
|
||||||
|
GROUP BY id_cliente
|
||||||
|
),
|
||||||
|
pedido_mes AS (
|
||||||
|
SELECT DISTINCT id_cliente FROM (
|
||||||
|
SELECT id_cliente FROM vw_pedidos_erp
|
||||||
|
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
UNION
|
||||||
|
SELECT id_cliente FROM sar.pedidos
|
||||||
|
WHERE situa != 3 AND id_empresa = ${idEmpresa}
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
) t
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
c.id_cliente,
|
c.id_cliente,
|
||||||
c.nome,
|
TRIM(c.nome) AS nome,
|
||||||
MAX(p.dt_pedido) AS dt_ultima_compra,
|
TRIM(c.razao) AS razao,
|
||||||
MAX(p.total)::text AS ultima_compra_valor
|
TO_CHAR(up.dt_max, 'YYYY-MM-DD') AS dt_ultimo_pedido,
|
||||||
|
(CURRENT_DATE - up.dt_max::date) AS dias_sem_pedido,
|
||||||
|
(up.dt_max IS NOT NULL) AS comprou_antes,
|
||||||
|
ct.whatsapp
|
||||||
FROM vw_clientes c
|
FROM vw_clientes c
|
||||||
LEFT JOIN vw_pedidos_erp p
|
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
|
||||||
ON p.id_cliente = c.id_cliente
|
LEFT JOIN pedido_mes pm ON pm.id_cliente = c.id_cliente
|
||||||
AND p.id_empresa = ${idEmpresa}
|
LEFT JOIN LATERAL (
|
||||||
AND p.situa != 5
|
SELECT TRIM(whatsapp) AS whatsapp
|
||||||
|
FROM sar.vw_contatos
|
||||||
|
WHERE id_corrent = c.id_cliente
|
||||||
|
AND whatsapp IS NOT NULL AND TRIM(whatsapp) != ''
|
||||||
|
AND ativo = 1
|
||||||
|
LIMIT 1
|
||||||
|
) ct ON true
|
||||||
WHERE c.cod_vendedor = ${codVendedor}
|
WHERE c.cod_vendedor = ${codVendedor}
|
||||||
AND c.ativo = 1
|
AND c.ativo = 1
|
||||||
GROUP BY c.id_cliente, c.nome
|
AND pm.id_cliente IS NULL
|
||||||
HAVING MAX(p.dt_pedido) IS NULL
|
ORDER BY up.dt_max ASC NULLS FIRST
|
||||||
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
|
||||||
ORDER BY dt_ultima_compra ASC NULLS FIRST
|
|
||||||
LIMIT 10
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo.
|
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo.
|
||||||
@@ -257,11 +261,20 @@ export class DashboardService {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => b.valorMeta - a.valorMeta);
|
.sort((a, b) => b.valorMeta - a.valorMeta);
|
||||||
|
|
||||||
|
const naoPositivados = naoPositivadosRows.map((c) => ({
|
||||||
|
idCliente: Number(c.id_cliente),
|
||||||
|
nome: c.nome,
|
||||||
|
razao: c.razao ?? null,
|
||||||
|
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
|
||||||
|
ultimoPedido: c.dt_ultimo_pedido ?? null,
|
||||||
|
comprouAntes: Boolean(c.comprou_antes),
|
||||||
|
whatsapp: c.whatsapp ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: { atingido, total: targetAmount, pct, falta },
|
meta: { atingido, total: targetAmount, pct, falta },
|
||||||
metaDimensao,
|
metaDimensao,
|
||||||
metasPorGrupo,
|
metasPorGrupo,
|
||||||
comissao: { fixa, flex, total: fixa + flex },
|
|
||||||
pedidosMes,
|
pedidosMes,
|
||||||
pedidosRecentes: recentRows.map((o) => ({
|
pedidosRecentes: recentRows.map((o) => ({
|
||||||
id: `erp-${o.id_pedido}`,
|
id: `erp-${o.id_pedido}`,
|
||||||
@@ -281,14 +294,8 @@ export class DashboardService {
|
|||||||
createdAt: new Date(o.dt_pedido).toISOString(),
|
createdAt: new Date(o.dt_pedido).toISOString(),
|
||||||
fonte: 'erp' as const,
|
fonte: 'erp' as const,
|
||||||
})),
|
})),
|
||||||
clientesInativos: inactiveClients.map((c) => ({
|
naoPositivados,
|
||||||
idCliente: Number(c.id_cliente),
|
totalNaoPositivados: naoPositivados.length,
|
||||||
nome: c.nome,
|
|
||||||
diasSemCompra: c.dt_ultima_compra
|
|
||||||
? Math.floor((now.getTime() - c.dt_ultima_compra.getTime()) / 86_400_000)
|
|
||||||
: 999,
|
|
||||||
ultimaCompraValor: c.ultima_compra_valor,
|
|
||||||
})),
|
|
||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
33
apps/api/src/app/reports/reports.controller.ts
Normal file
33
apps/api/src/app/reports/reports.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
ReportMetaQuerySchema,
|
||||||
|
ReportAbcQuerySchema,
|
||||||
|
type ReportMetaResponse,
|
||||||
|
type ReportCarteiraResponse,
|
||||||
|
type ReportAbcResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
class ReportMetaQueryDto extends createZodDto(ReportMetaQuerySchema) {}
|
||||||
|
class ReportAbcQueryDto extends createZodDto(ReportAbcQuerySchema) {}
|
||||||
|
|
||||||
|
@Controller({ path: 'reports' })
|
||||||
|
export class ReportsController {
|
||||||
|
constructor(private readonly reports: ReportsService) {}
|
||||||
|
|
||||||
|
@Get('meta')
|
||||||
|
meta(@Query() query: ReportMetaQueryDto): Promise<ReportMetaResponse> {
|
||||||
|
return this.reports.metaVsRealizado(ReportMetaQuerySchema.parse(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('carteira')
|
||||||
|
carteira(): Promise<ReportCarteiraResponse> {
|
||||||
|
return this.reports.carteira();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('abc')
|
||||||
|
abc(@Query() query: ReportAbcQueryDto): Promise<ReportAbcResponse> {
|
||||||
|
return this.reports.abcProdutos(ReportAbcQuerySchema.parse(query));
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/reports/reports.module.ts
Normal file
9
apps/api/src/app/reports/reports.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ReportsController } from './reports.controller';
|
||||||
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ReportsController],
|
||||||
|
providers: [ReportsService],
|
||||||
|
})
|
||||||
|
export class ReportsModule {}
|
||||||
223
apps/api/src/app/reports/reports.service.ts
Normal file
223
apps/api/src/app/reports/reports.service.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type {
|
||||||
|
ReportMetaQuery,
|
||||||
|
ReportMetaResponse,
|
||||||
|
ReportCarteiraResponse,
|
||||||
|
ReportAbcQuery,
|
||||||
|
ReportAbcResponse,
|
||||||
|
AbcProduto,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
|
function d(v: unknown): string {
|
||||||
|
return v != null ? String(v) : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function n(v: unknown): number {
|
||||||
|
return v != null ? Number(v) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReportsService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
async metaVsRealizado(query: ReportMetaQuery): Promise<ReportMetaResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
const { mes, ano } = query;
|
||||||
|
|
||||||
|
interface RealizadoRow {
|
||||||
|
realizado: unknown;
|
||||||
|
comissao: unknown;
|
||||||
|
total_pedidos: unknown;
|
||||||
|
}
|
||||||
|
const [realizadoRows, metaRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<RealizadoRow[]>(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(total), 0) AS realizado,
|
||||||
|
COALESCE(SUM(comissao), 0) AS comissao,
|
||||||
|
COUNT(id) AS total_pedidos
|
||||||
|
FROM sar.pedidos
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND cod_vendedor = ${codVendedor}
|
||||||
|
AND situa IN (2, 4)
|
||||||
|
AND EXTRACT(MONTH FROM dt_pedido) = ${mes}
|
||||||
|
AND EXTRACT(YEAR FROM dt_pedido) = ${ano}
|
||||||
|
`),
|
||||||
|
prisma.metaRepresentante.findUnique({
|
||||||
|
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano, mes } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const realizado = n(realizadoRows[0]?.realizado);
|
||||||
|
const comissao = n(realizadoRows[0]?.comissao);
|
||||||
|
const totalPedidos = n(realizadoRows[0]?.total_pedidos);
|
||||||
|
const meta = metaRows ? Number(metaRows.metaValor) : 0;
|
||||||
|
const percentual = meta > 0 ? (realizado / meta) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mes,
|
||||||
|
ano,
|
||||||
|
realizado: realizado.toFixed(2),
|
||||||
|
meta: meta.toFixed(2),
|
||||||
|
percentual: percentual.toFixed(2),
|
||||||
|
comissaoEstimada: comissao.toFixed(2),
|
||||||
|
totalPedidos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async carteira(): Promise<ReportCarteiraResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
interface CarteiraRow {
|
||||||
|
id_cliente: unknown;
|
||||||
|
nome: string | null;
|
||||||
|
razao: string | null;
|
||||||
|
ativo: unknown;
|
||||||
|
ultimo_pedido: string | null;
|
||||||
|
dias_sem_pedido: unknown;
|
||||||
|
total_pedidos: unknown;
|
||||||
|
ticket_medio: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
|
||||||
|
WITH ultimos AS (
|
||||||
|
SELECT
|
||||||
|
id_cliente,
|
||||||
|
MAX(dt_pedido) AS ultimo_pedido,
|
||||||
|
COUNT(id) AS total_pedidos,
|
||||||
|
CASE WHEN COUNT(id) > 0
|
||||||
|
THEN SUM(total)::numeric / COUNT(id)
|
||||||
|
ELSE 0
|
||||||
|
END AS ticket_medio
|
||||||
|
FROM sar.pedidos
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND cod_vendedor = ${codVendedor}
|
||||||
|
AND situa <> 3
|
||||||
|
GROUP BY id_cliente
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.id_cliente,
|
||||||
|
TRIM(c.nome) AS nome,
|
||||||
|
TRIM(c.razao) AS razao,
|
||||||
|
c.ativo,
|
||||||
|
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
|
||||||
|
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
|
||||||
|
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
|
||||||
|
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio
|
||||||
|
FROM sar.vw_clientes c
|
||||||
|
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
|
||||||
|
WHERE c.id_empresa = ${idEmpresa}
|
||||||
|
AND c.cod_vendedor = ${codVendedor}
|
||||||
|
ORDER BY u.ultimo_pedido ASC NULLS FIRST, c.nome ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const data = rows.map((r) => ({
|
||||||
|
idCliente: n(r.id_cliente),
|
||||||
|
nome: r.nome ?? null,
|
||||||
|
razao: r.razao ?? null,
|
||||||
|
ativo: n(r.ativo),
|
||||||
|
ultimoPedido: r.ultimo_pedido ?? null,
|
||||||
|
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
|
||||||
|
totalPedidos: n(r.total_pedidos),
|
||||||
|
ticketMedio: d(r.ticket_medio),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const inativos30 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 30).length;
|
||||||
|
const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
|
||||||
|
const semPedido = data.filter((c) => c.totalPedidos === 0).length;
|
||||||
|
|
||||||
|
return { data, total: data.length, inativos30, inativos60, semPedido };
|
||||||
|
}
|
||||||
|
|
||||||
|
async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
const { mes, ano } = query;
|
||||||
|
|
||||||
|
const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : '';
|
||||||
|
const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : '';
|
||||||
|
|
||||||
|
interface AbcRow {
|
||||||
|
cod_produto: string | null;
|
||||||
|
desc_produto: string | null;
|
||||||
|
total_faturado: unknown;
|
||||||
|
qtd_vendida: unknown;
|
||||||
|
desconto_medio: unknown;
|
||||||
|
comissao_total: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<AbcRow[]>(`
|
||||||
|
SELECT
|
||||||
|
pi.cod_produto,
|
||||||
|
pi.desc_produto,
|
||||||
|
SUM(pi.total) AS total_faturado,
|
||||||
|
SUM(pi.qtd) AS qtd_vendida,
|
||||||
|
AVG(pi.desconto_perc) AS desconto_medio,
|
||||||
|
SUM(pi.comissao) AS comissao_total
|
||||||
|
FROM sar.pedido_itens pi
|
||||||
|
JOIN sar.pedidos p ON p.id = pi.id_pedido
|
||||||
|
WHERE p.id_empresa = ${idEmpresa}
|
||||||
|
AND p.cod_vendedor = ${codVendedor}
|
||||||
|
AND p.situa NOT IN (0, 3)
|
||||||
|
${mesFilter}
|
||||||
|
${anoFilter}
|
||||||
|
GROUP BY pi.cod_produto, pi.desc_produto
|
||||||
|
ORDER BY total_faturado DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const totalGeral = rows.reduce((s, r) => s + n(r.total_faturado), 0);
|
||||||
|
|
||||||
|
let acumulado = 0;
|
||||||
|
const data: AbcProduto[] = rows.map((r) => {
|
||||||
|
const total = n(r.total_faturado);
|
||||||
|
const participacao = totalGeral > 0 ? (total / totalGeral) * 100 : 0;
|
||||||
|
acumulado += participacao;
|
||||||
|
const curva: 'A' | 'B' | 'C' = acumulado <= 80 ? 'A' : acumulado <= 95 ? 'B' : 'C';
|
||||||
|
return {
|
||||||
|
codProduto: r.cod_produto ?? null,
|
||||||
|
descProduto: r.desc_produto ?? '(sem descrição)',
|
||||||
|
totalFaturado: total.toFixed(2),
|
||||||
|
qtdVendida: d(r.qtd_vendida),
|
||||||
|
participacao: participacao.toFixed(2),
|
||||||
|
curva,
|
||||||
|
descontoMedio: n(r.desconto_medio).toFixed(2),
|
||||||
|
comissaoTotal: n(r.comissao_total).toFixed(2),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const meses = [
|
||||||
|
'Jan',
|
||||||
|
'Fev',
|
||||||
|
'Mar',
|
||||||
|
'Abr',
|
||||||
|
'Mai',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Ago',
|
||||||
|
'Set',
|
||||||
|
'Out',
|
||||||
|
'Nov',
|
||||||
|
'Dez',
|
||||||
|
];
|
||||||
|
const periodo =
|
||||||
|
mes != null && ano != null
|
||||||
|
? `${meses[mes - 1]}/${ano}`
|
||||||
|
: ano != null
|
||||||
|
? String(ano)
|
||||||
|
: 'Acumulado';
|
||||||
|
|
||||||
|
return { data, totalFaturado: totalGeral.toFixed(2), periodo };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,35 @@
|
|||||||
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Tag,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
Spin,
|
||||||
|
Alert,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
Modal,
|
||||||
|
Tooltip,
|
||||||
|
message,
|
||||||
|
Badge,
|
||||||
|
} from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||||
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
||||||
import type { PedidoSummary } from '@sar/api-interface';
|
import type { CtrTitulo, NotaFiscal, PedidoSummary, TopProduto } from '@sar/api-interface';
|
||||||
import { SITUA_LABEL } from '@sar/api-interface';
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useClientDetail } from '../../lib/queries/clients';
|
import {
|
||||||
import { useClientOrders } from '../../lib/queries/orders';
|
useClientDetail,
|
||||||
|
useClientCtr,
|
||||||
|
useClientCtrList,
|
||||||
|
useClientNotasFiscais,
|
||||||
|
useClientOrdersHistory,
|
||||||
|
useClientTopProdutos,
|
||||||
|
} from '../../lib/queries/clients';
|
||||||
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const ACTIVITY_COLOR: Record<string, string> = {
|
const ACTIVITY_COLOR: Record<string, string> = {
|
||||||
active: 'success',
|
active: 'success',
|
||||||
@@ -20,60 +42,182 @@ const ACTIVITY_LABEL: Record<string, string> = {
|
|||||||
inactive: 'Inativo',
|
inactive: 'Inativo',
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderColumns: TableColumnsType<PedidoSummary> = [
|
const SITUA_COLOR: Record<number, string> = {
|
||||||
{
|
0: 'default',
|
||||||
title: 'Nº',
|
1: 'warning',
|
||||||
dataIndex: 'numPedSar',
|
2: 'processing',
|
||||||
width: 120,
|
3: 'error',
|
||||||
render: (num: string, row: PedidoSummary) => (
|
4: 'success',
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
};
|
||||||
{num}
|
|
||||||
</Link>
|
function orderColumns(withFonte: boolean): TableColumnsType<PedidoSummary> {
|
||||||
),
|
const cols: TableColumnsType<PedidoSummary> = [
|
||||||
},
|
{
|
||||||
{
|
title: 'Nº Pedido',
|
||||||
title: 'Status',
|
dataIndex: 'numPedSar',
|
||||||
dataIndex: 'situa',
|
render: (num: string, row: PedidoSummary) => (
|
||||||
width: 140,
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
render: (s: number) => {
|
{num}
|
||||||
const colorMap: Record<number, string> = {
|
</Link>
|
||||||
1: 'warning',
|
),
|
||||||
2: 'processing',
|
|
||||||
3: 'error',
|
|
||||||
4: 'success',
|
|
||||||
};
|
|
||||||
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: 'Status',
|
||||||
title: 'Total',
|
dataIndex: 'situa',
|
||||||
dataIndex: 'total',
|
width: 130,
|
||||||
width: 130,
|
render: (s: number) => (
|
||||||
align: 'right',
|
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
|
||||||
render: (v: string) =>
|
),
|
||||||
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
},
|
||||||
},
|
{
|
||||||
{
|
title: 'Total',
|
||||||
title: 'Data',
|
dataIndex: 'total',
|
||||||
dataIndex: 'dtPedido',
|
width: 130,
|
||||||
width: 130,
|
align: 'right' as const,
|
||||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
render: (v: string) =>
|
||||||
},
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
];
|
},
|
||||||
|
{
|
||||||
|
title: 'Data',
|
||||||
|
dataIndex: 'dtPedido',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (withFonte) {
|
||||||
|
cols.push({
|
||||||
|
title: 'Origem',
|
||||||
|
dataIndex: 'fonte',
|
||||||
|
width: 70,
|
||||||
|
render: (f: string) => (
|
||||||
|
<Tag color={f === 'sar' ? 'blue' : 'default'} style={{ fontSize: 11 }}>
|
||||||
|
{f === 'sar' ? 'SAR' : 'ERP'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientDetailPage() {
|
export function ClientDetailPage() {
|
||||||
const { id } = useParams({ from: '/clientes/$id' });
|
const { id } = useParams({ from: '/clientes/$id' });
|
||||||
const idNum = Number(id);
|
const idNum = Number(id);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [addContactOpen, setAddContactOpen] = useState(false);
|
||||||
|
const [ordersModalOpen, setOrdersModalOpen] = useState(false);
|
||||||
|
const [produtosModalOpen, setProdutosModalOpen] = useState(false);
|
||||||
|
const [notasModalOpen, setNotasModalOpen] = useState(false);
|
||||||
|
|
||||||
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
|
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
|
||||||
const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum);
|
const { data: ctr } = useClientCtr(idNum);
|
||||||
|
const { data: ctrList = [], isLoading: ctrListLoading } = useClientCtrList(idNum, 60);
|
||||||
|
const { data: orders = [], isLoading: ordersLoading } = useClientOrdersHistory(idNum, 50);
|
||||||
|
const { data: topProdutos = [], isLoading: produtosLoading } = useClientTopProdutos(idNum, 30);
|
||||||
|
const { data: notas = [], isLoading: notasLoading } = useClientNotasFiscais(idNum, 30);
|
||||||
|
|
||||||
|
const [msgApi, msgCtx] = message.useMessage();
|
||||||
|
|
||||||
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
if (clientError || !client)
|
if (clientError || !client)
|
||||||
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
|
return <Alert type="error" title="Cliente não encontrado." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
|
const today: string = new Date().toISOString().slice(0, 10);
|
||||||
|
const ordersPreview = orders.slice(0, 5);
|
||||||
|
const produtosPreview = topProdutos.slice(0, 5);
|
||||||
|
const notasPreview = notas.slice(0, 5);
|
||||||
|
const ctrPreview = ctrList.slice(0, 8);
|
||||||
|
|
||||||
|
const notaColumns: TableColumnsType<NotaFiscal> = [
|
||||||
|
{
|
||||||
|
title: 'NF',
|
||||||
|
dataIndex: 'numero',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number, r: NotaFiscal) => `${v}-${r.serie}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Emissão',
|
||||||
|
dataIndex: 'dtEmissao',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Valor',
|
||||||
|
dataIndex: 'vlNf',
|
||||||
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Chave NF-e',
|
||||||
|
dataIndex: 'chaveAcessoNfe',
|
||||||
|
render: (chave: string | null) =>
|
||||||
|
chave ? (
|
||||||
|
<Space size={4}>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 11, fontFamily: 'monospace', color: '#666' }}
|
||||||
|
ellipsis={{ tooltip: chave }}
|
||||||
|
>
|
||||||
|
{chave}
|
||||||
|
</Text>
|
||||||
|
<Tooltip title="Copiar chave">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(chave)
|
||||||
|
.then(() => void msgApi.success('Chave copiada!'))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const produtoColumns: TableColumnsType<TopProduto> = [
|
||||||
|
{
|
||||||
|
title: 'Produto',
|
||||||
|
dataIndex: 'descricao',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Qtd Total',
|
||||||
|
dataIndex: 'qtdTotal',
|
||||||
|
width: 100,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number, r: TopProduto) =>
|
||||||
|
`${v.toLocaleString('pt-BR', { maximumFractionDigits: 0 })} ${r.unidade ?? ''}`.trim(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'numPedidos',
|
||||||
|
width: 80,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Último Preço',
|
||||||
|
dataIndex: 'ultimoPreco',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) =>
|
||||||
|
v > 0 ? v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Última Compra',
|
||||||
|
dataIndex: 'ultimaCompra',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24, maxWidth: '100%', overflowX: 'hidden' }}>
|
||||||
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
||||||
<Link to="/clientes">← Clientes</Link>
|
<Link to="/clientes">← Clientes</Link>
|
||||||
<Title level={3} style={{ margin: 0 }}>
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
@@ -82,6 +226,9 @@ export function ClientDetailPage() {
|
|||||||
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
|
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
|
||||||
{ACTIVITY_LABEL[client.activityStatus]}
|
{ACTIVITY_LABEL[client.activityStatus]}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
<Button icon={<UserAddOutlined />} onClick={() => setAddContactOpen(true)}>
|
||||||
|
Novo Contato
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
|
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
|
||||||
@@ -121,20 +268,306 @@ export function ClientDetailPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<ClientContacts idCliente={idNum} />
|
{/* ── CTR + NF-e lado a lado ── */}
|
||||||
|
{msgCtx}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
|
||||||
|
{/* ── Contas a Receber ── */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space size={8}>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
Contas a Receber
|
||||||
|
</Text>
|
||||||
|
{ctr && ctr.qtdAberto > 0 && (
|
||||||
|
<Tag color={ctr.qtdVencido > 0 ? 'error' : 'warning'}>
|
||||||
|
{ctr.qtdAberto} título{ctr.qtdAberto > 1 ? 's' : ''} —{' '}
|
||||||
|
{ctr.totalAberto.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '0 0 10px' }} />
|
||||||
|
<Table<CtrTitulo>
|
||||||
|
rowKey="idCtr"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
loading={ctrListLoading}
|
||||||
|
dataSource={ctrPreview}
|
||||||
|
locale={{ emptyText: 'Nenhum título em aberto.' }}
|
||||||
|
scroll={{ x: 'max-content', y: ctrList.length > 8 ? 260 : undefined }}
|
||||||
|
style={{ maxWidth: '100%' }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Título / Parcela',
|
||||||
|
dataIndex: 'numero',
|
||||||
|
render: (num: string, r: CtrTitulo) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Text style={{ fontSize: 12 }}>
|
||||||
|
{r.prefixo}-{num}
|
||||||
|
</Text>
|
||||||
|
{r.dtVencimento < today ? (
|
||||||
|
<Badge
|
||||||
|
status="error"
|
||||||
|
text={
|
||||||
|
<Text type="danger" style={{ fontSize: 11 }}>
|
||||||
|
{Math.ceil(
|
||||||
|
(new Date(today).getTime() - new Date(r.dtVencimento).getTime()) /
|
||||||
|
86400000,
|
||||||
|
)}
|
||||||
|
d atraso
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
status="warning"
|
||||||
|
text={
|
||||||
|
<Text type="warning" style={{ fontSize: 11 }}>
|
||||||
|
a vencer
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vencimento',
|
||||||
|
dataIndex: 'dtVencimento',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text type={v < today ? 'danger' : undefined} style={{ fontSize: 12 }}>
|
||||||
|
{new Date(v + 'T12:00:00').toLocaleDateString('pt-BR')}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Saldo',
|
||||||
|
dataIndex: 'saldo',
|
||||||
|
width: 110,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => (
|
||||||
|
<Text strong style={{ fontSize: 12 }}>
|
||||||
|
{v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
footer={
|
||||||
|
ctrList.length > 8
|
||||||
|
? () => (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Exibindo 8 de {ctrList.length}
|
||||||
|
{ctrList.length === 60 ? '+' : ''} títulos • total em aberto:{' '}
|
||||||
|
{ctr?.totalAberto.toLocaleString('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Divider orientation="left">Últimos Pedidos</Divider>
|
{/* ── Notas Fiscais ── */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
Notas Fiscais
|
||||||
|
</Text>
|
||||||
|
{notas.length > 5 && (
|
||||||
|
<Button size="small" type="link" onClick={() => setNotasModalOpen(true)}>
|
||||||
|
Ver todas ({notas.length}
|
||||||
|
{notas.length === 30 ? '+' : ''})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '0 0 10px' }} />
|
||||||
|
<Table<NotaFiscal>
|
||||||
|
rowKey="idNf"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
loading={notasLoading}
|
||||||
|
dataSource={notasPreview}
|
||||||
|
locale={{ emptyText: 'Nenhuma nota fiscal.' }}
|
||||||
|
style={{ maxWidth: '100%' }}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
columns={notaColumns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>Notas Fiscais — {client.razao ?? client.nome}</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
({notas.length}
|
||||||
|
{notas.length === 30 ? '+' : ''} notas)
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={notasModalOpen}
|
||||||
|
onCancel={() => setNotasModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={900}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Table<NotaFiscal>
|
||||||
|
rowKey="idNf"
|
||||||
|
columns={notaColumns}
|
||||||
|
dataSource={notas}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
|
||||||
|
size="small"
|
||||||
|
scroll={{ y: 460, x: 'max-content' }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ClientContacts
|
||||||
|
idCliente={idNum}
|
||||||
|
modalOpen={addContactOpen}
|
||||||
|
onModalClose={() => setAddContactOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Produtos Mais Comprados ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
Produtos Mais Comprados
|
||||||
|
</Text>
|
||||||
|
{topProdutos.length > 5 && (
|
||||||
|
<Button size="small" type="link" onClick={() => setProdutosModalOpen(true)}>
|
||||||
|
Ver todos ({topProdutos.length}
|
||||||
|
{topProdutos.length === 30 ? '+' : ''})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '0 0 12px' }} />
|
||||||
|
|
||||||
|
<Table<TopProduto>
|
||||||
|
rowKey="idProduto"
|
||||||
|
columns={produtoColumns}
|
||||||
|
dataSource={produtosPreview}
|
||||||
|
loading={produtosLoading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum produto encontrado.' }}
|
||||||
|
style={{ marginBottom: 24, maxWidth: '100%' }}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>Produtos — {client.razao ?? client.nome}</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
({topProdutos.length}
|
||||||
|
{topProdutos.length === 30 ? '+' : ''} produtos)
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={produtosModalOpen}
|
||||||
|
onCancel={() => setProdutosModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={820}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Table<TopProduto>
|
||||||
|
rowKey="idProduto"
|
||||||
|
columns={produtoColumns}
|
||||||
|
dataSource={topProdutos}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
|
||||||
|
size="small"
|
||||||
|
scroll={{ y: 460 }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Últimos Pedidos ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
Últimos Pedidos
|
||||||
|
</Text>
|
||||||
|
{orders.length > 5 && (
|
||||||
|
<Button size="small" type="link" onClick={() => setOrdersModalOpen(true)}>
|
||||||
|
Ver todos ({orders.length}
|
||||||
|
{orders.length === 50 ? '+' : ''})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '0 0 12px' }} />
|
||||||
|
|
||||||
<Table<PedidoSummary>
|
<Table<PedidoSummary>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={orderColumns}
|
columns={orderColumns(false)}
|
||||||
dataSource={orders ?? []}
|
dataSource={ordersPreview}
|
||||||
loading={ordersLoading}
|
loading={ordersLoading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
|
||||||
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
|
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
|
||||||
|
style={{ marginBottom: 24, maxWidth: '100%' }}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
/>
|
/>
|
||||||
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
|
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
|
||||||
|
|
||||||
|
{/* ── Modal Ver Todos Pedidos ── */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>Pedidos — {client.razao ?? client.nome}</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
({orders.length}
|
||||||
|
{orders.length === 50 ? '+' : ''} registros)
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={ordersModalOpen}
|
||||||
|
onCancel={() => setOrdersModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={780}
|
||||||
|
centered
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Table<PedidoSummary>
|
||||||
|
rowKey="id"
|
||||||
|
columns={orderColumns(true)}
|
||||||
|
dataSource={orders}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
|
||||||
|
size="small"
|
||||||
|
scroll={{ y: 460 }}
|
||||||
|
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
557
apps/web/src/cockpits/rep/RelatoriosPage.tsx
Normal file
557
apps/web/src/cockpits/rep/RelatoriosPage.tsx
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Progress,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { AlertOutlined, BarChartOutlined, RiseOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface';
|
||||||
|
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
function fmt(v: number | string): string {
|
||||||
|
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(v: string | null | undefined): string {
|
||||||
|
if (!v) return '—';
|
||||||
|
return new Date(v).toLocaleDateString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TabMeta() {
|
||||||
|
const now = dayjs();
|
||||||
|
const [mes, setMes] = useState(now.month() + 1);
|
||||||
|
const [ano, setAno] = useState(now.year());
|
||||||
|
|
||||||
|
const { data, isLoading } = useReportMeta({ mes, ano });
|
||||||
|
|
||||||
|
const realizado = Number(data?.realizado ?? 0);
|
||||||
|
const meta = Number(data?.meta ?? 0);
|
||||||
|
const percentual = Number(data?.percentual ?? 0);
|
||||||
|
const comissao = Number(data?.comissaoEstimada ?? 0);
|
||||||
|
const bateuMeta = meta > 0 && realizado >= meta;
|
||||||
|
|
||||||
|
const progressColor = percentual >= 100 ? '#52c41a' : percentual >= 70 ? '#faad14' : '#003B8E';
|
||||||
|
|
||||||
|
const meses = [
|
||||||
|
'Jan',
|
||||||
|
'Fev',
|
||||||
|
'Mar',
|
||||||
|
'Abr',
|
||||||
|
'Mai',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Ago',
|
||||||
|
'Set',
|
||||||
|
'Out',
|
||||||
|
'Nov',
|
||||||
|
'Dez',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 20 }}>
|
||||||
|
<Select value={mes} onChange={setMes} style={{ width: 90 }}>
|
||||||
|
{meses.map((m, i) => (
|
||||||
|
<Option key={i + 1} value={i + 1}>
|
||||||
|
{m}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<DatePicker
|
||||||
|
picker="year"
|
||||||
|
value={dayjs().year(ano)}
|
||||||
|
onChange={(d) => d && setAno(d.year())}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
allowClear={false}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: '24px 28px' } }}
|
||||||
|
>
|
||||||
|
<Row gutter={[32, 24]} align="middle">
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
||||||
|
>
|
||||||
|
Realizado {meses[mes - 1]}/{ano}
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
color: bateuMeta ? '#52c41a' : '#003B8E',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmt(realizado)}
|
||||||
|
</Text>
|
||||||
|
{bateuMeta && (
|
||||||
|
<Tag
|
||||||
|
color="success"
|
||||||
|
style={{ marginLeft: 10, fontWeight: 700, borderRadius: 20 }}
|
||||||
|
>
|
||||||
|
Meta batida!
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
de {fmt(meta)} de meta
|
||||||
|
</Text>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(percentual, 100)}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
strokeColor={progressColor}
|
||||||
|
format={() => `${percentual.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="Pedidos Transmitidos"
|
||||||
|
value={data?.totalPedidos ?? 0}
|
||||||
|
valueStyle={{ color: '#003B8E' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="Comissão Estimada"
|
||||||
|
value={fmt(comissao)}
|
||||||
|
valueStyle={{ 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 }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="% Atingido"
|
||||||
|
value={`${percentual.toFixed(1)}%`}
|
||||||
|
valueStyle={{ color: progressColor, fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{meta === 0 && (
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 8, border: '1px solid #fde68a', background: '#fffbeb' }}
|
||||||
|
styles={{ body: { padding: '12px 16px' } }}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<AlertOutlined style={{ color: '#d97706' }} />
|
||||||
|
<Text style={{ color: '#92400e' }}>
|
||||||
|
Nenhuma meta cadastrada para {meses[mes - 1]}/{ano}. Fale com seu supervisor.
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aba 2: Carteira de Clientes ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
|
||||||
|
|
||||||
|
function TabCarteira() {
|
||||||
|
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
|
||||||
|
const { data, isLoading } = useReportCarteira();
|
||||||
|
|
||||||
|
const clientes = data?.data ?? [];
|
||||||
|
|
||||||
|
const filtered = clientes.filter((c: CarteiraCliente) => {
|
||||||
|
if (filtro === 'inativos30') return c.diasSemPedido != null && c.diasSemPedido >= 30;
|
||||||
|
if (filtro === 'inativos60') return c.diasSemPedido != null && c.diasSemPedido >= 60;
|
||||||
|
if (filtro === 'semPedido') return c.totalPedidos === 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function statusBadge(c: CarteiraCliente) {
|
||||||
|
if (c.totalPedidos === 0)
|
||||||
|
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedidos</Text>} />;
|
||||||
|
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="#ef4444"
|
||||||
|
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="#f59e0b"
|
||||||
|
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Inativo 30d+</Text>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<CarteiraCliente> = [
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
key: 'cliente',
|
||||||
|
render: (_: unknown, c: CarteiraCliente) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text strong style={{ fontSize: 13 }}>
|
||||||
|
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
|
||||||
|
</Text>
|
||||||
|
{c.razao && c.nome && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{c.nome}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
key: 'status',
|
||||||
|
width: 140,
|
||||||
|
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Último Pedido',
|
||||||
|
dataIndex: 'ultimoPedido',
|
||||||
|
width: 130,
|
||||||
|
render: (v: string | null) => (
|
||||||
|
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Dias Sem Pedido',
|
||||||
|
dataIndex: 'diasSemPedido',
|
||||||
|
width: 140,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number | null) =>
|
||||||
|
v != null ? (
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
|
||||||
|
>
|
||||||
|
{v}d
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">—</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'totalPedidos',
|
||||||
|
width: 90,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ticket Médio',
|
||||||
|
dataIndex: 'ticketMedio',
|
||||||
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data && (
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||||
|
{[
|
||||||
|
{ key: 'todos', label: 'Total', value: data.total, color: '#003B8E' },
|
||||||
|
{ key: 'inativos30', label: 'Inativos 30d+', value: data.inativos30, color: '#f59e0b' },
|
||||||
|
{ key: 'inativos60', label: 'Inativos 60d+', value: data.inativos60, color: '#ef4444' },
|
||||||
|
{ key: 'semPedido', label: 'Sem Pedido', value: data.semPedido, color: '#94A3B8' },
|
||||||
|
].map((item) => (
|
||||||
|
<Col key={item.key} xs={12} md={6}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={() => setFiltro(item.key as FiltroCarteira)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: filtro === item.key ? `2px solid ${item.color}` : '1px solid #EBF0F5',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '12px 16px' } }}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={item.label}
|
||||||
|
value={item.value}
|
||||||
|
valueStyle={{ color: item.color, fontSize: 24 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<CarteiraCliente>
|
||||||
|
rowKey="idCliente"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filtered}
|
||||||
|
loading={isLoading}
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 15, showSizeChanger: false }}
|
||||||
|
scroll={{ x: 700 }}
|
||||||
|
style={{ borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aba 3: Curva ABC ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
|
||||||
|
|
||||||
|
function TabAbc() {
|
||||||
|
const now = dayjs();
|
||||||
|
const [mes, setMes] = useState<number | undefined>(now.month() + 1);
|
||||||
|
const [ano, setAno] = useState<number | undefined>(now.year());
|
||||||
|
|
||||||
|
const { data, isLoading } = useReportAbc({ mes, ano });
|
||||||
|
|
||||||
|
const meses = [
|
||||||
|
'Jan',
|
||||||
|
'Fev',
|
||||||
|
'Mar',
|
||||||
|
'Abr',
|
||||||
|
'Mai',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Ago',
|
||||||
|
'Set',
|
||||||
|
'Out',
|
||||||
|
'Nov',
|
||||||
|
'Dez',
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: TableColumnsType<AbcProduto> = [
|
||||||
|
{
|
||||||
|
title: 'Curva',
|
||||||
|
dataIndex: 'curva',
|
||||||
|
width: 70,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Tag
|
||||||
|
color={CURVA_COLOR[v]}
|
||||||
|
style={{ fontWeight: 800, borderRadius: 20, fontSize: 13, padding: '0 10px' }}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Produto',
|
||||||
|
key: 'produto',
|
||||||
|
render: (_: unknown, p: AbcProduto) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
{p.codProduto && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{p.codProduto}</Text>}
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: 500 }}>{p.descProduto}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total Faturado',
|
||||||
|
dataIndex: 'totalFaturado',
|
||||||
|
width: 150,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string, p: AbcProduto) => (
|
||||||
|
<Space direction="vertical" size={0} style={{ alignItems: 'flex-end' }}>
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{Number(p.participacao).toFixed(1)}% do total
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Qtd. Vendida',
|
||||||
|
dataIndex: 'qtdVendida',
|
||||||
|
width: 110,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text className="tabular-nums">
|
||||||
|
{Number(v).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Desc. Médio',
|
||||||
|
dataIndex: 'descontoMedio',
|
||||||
|
width: 110,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text className="tabular-nums" style={{ color: Number(v) > 5 ? '#f59e0b' : '#475569' }}>
|
||||||
|
{Number(v).toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Comissão',
|
||||||
|
dataIndex: 'comissaoTotal',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text className="tabular-nums" style={{ color: '#22c55e', fontWeight: 600 }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 20 }}>
|
||||||
|
<Select
|
||||||
|
value={mes}
|
||||||
|
onChange={(v) => setMes(v)}
|
||||||
|
allowClear
|
||||||
|
placeholder="Mês"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
onClear={() => setMes(undefined)}
|
||||||
|
>
|
||||||
|
{meses.map((m, i) => (
|
||||||
|
<Option key={i + 1} value={i + 1}>
|
||||||
|
{m}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<DatePicker
|
||||||
|
picker="year"
|
||||||
|
value={ano ? dayjs().year(ano) : null}
|
||||||
|
onChange={(d) => setAno(d?.year())}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
placeholder="Ano"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
{data && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Período: <strong>{data.periodo}</strong> — Total:{' '}
|
||||||
|
<strong>{fmt(data.totalFaturado)}</strong>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<AbcProduto>
|
||||||
|
rowKey={(r) => r.codProduto ?? r.descProduto}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||||
|
scroll={{ x: 800 }}
|
||||||
|
rowClassName={(r) => (r.curva === 'A' ? 'row-curva-a' : '')}
|
||||||
|
style={{ borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.row-curva-a td { background: #f0f5ff !important; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── RelatoriosPage ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RelatoriosPage() {
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: 'meta',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<RiseOutlined />
|
||||||
|
Desempenho vs. Meta
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: <TabMeta />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'carteira',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<TeamOutlined />
|
||||||
|
Carteira de Clientes
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: <TabCarteira />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'abc',
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<BarChartOutlined />
|
||||||
|
Curva ABC
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: <TabAbc />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
Relatórios
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||||
|
Análise de desempenho, carteira e mix de produtos.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultActiveKey="meta" items={tabs} size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
import { Card, Col, Flex, Progress, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Flex,
|
||||||
|
Progress,
|
||||||
|
Row,
|
||||||
|
Segmented,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
@@ -7,8 +21,9 @@ import {
|
|||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faClipboardList,
|
faClipboardList,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { WhatsAppOutlined } from '@ant-design/icons';
|
||||||
import type { MetaItem, PedidoSummary } from '@sar/api-interface';
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
import type { MetaItem, ClienteNaoPositivado, PedidoSummary } from '@sar/api-interface';
|
||||||
import { SITUA_LABEL } from '@sar/api-interface';
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||||
import { useCurrentUser } from '../../lib/queries/auth';
|
import { useCurrentUser } from '../../lib/queries/auth';
|
||||||
@@ -34,17 +49,19 @@ function greeting(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function today(): string {
|
function today(): string {
|
||||||
return new Date().toLocaleDateString('pt-BR', {
|
return new Date().toLocaleDateString('pt-BR', { day: 'numeric', month: 'long' });
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function num(v: number, dec = 0): string {
|
function num(v: number, dec = 0): string {
|
||||||
return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Célula "realizado / meta" — realizado em destaque (verde se bateu), meta abaixo.
|
function waLink(whatsapp: string): string {
|
||||||
|
const digits = whatsapp.replace(/\D/g, '');
|
||||||
|
const number = digits.startsWith('55') ? digits : `55${digits}`;
|
||||||
|
return `https://wa.me/${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
function MetaCell({
|
function MetaCell({
|
||||||
real,
|
real,
|
||||||
meta,
|
meta,
|
||||||
@@ -131,6 +148,170 @@ const metaColumns: TableColumnsType<MetaItem> = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── Não positivados ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type OrdemNP = 'longe' | 'recentes';
|
||||||
|
|
||||||
|
function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[]; total: number }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [ordem, setOrdem] = useState<OrdemNP>('longe');
|
||||||
|
const [expandido, setExpandido] = useState(false);
|
||||||
|
const PAGE = 10;
|
||||||
|
|
||||||
|
const sorted = [...clientes].sort((a, b) => {
|
||||||
|
if (ordem === 'longe') {
|
||||||
|
return (b.diasSemPedido ?? 999) - (a.diasSemPedido ?? 999);
|
||||||
|
}
|
||||||
|
// 'recentes': compraram antes primeiro, ordenados por último pedido desc
|
||||||
|
if (a.comprouAntes && !b.comprouAntes) return -1;
|
||||||
|
if (!a.comprouAntes && b.comprouAntes) return 1;
|
||||||
|
const da = a.ultimoPedido ?? '';
|
||||||
|
const db = b.ultimoPedido ?? '';
|
||||||
|
return db.localeCompare(da);
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = expandido ? sorted : sorted.slice(0, PAGE);
|
||||||
|
const restante = total - PAGE;
|
||||||
|
|
||||||
|
const columns: TableColumnsType<ClienteNaoPositivado> = [
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
key: 'cliente',
|
||||||
|
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ cursor: 'pointer', color: 'var(--jcs-blue)' }}
|
||||||
|
onClick={() =>
|
||||||
|
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{c.razao ?? c.nome}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Último pedido',
|
||||||
|
key: 'ultimo',
|
||||||
|
width: 160,
|
||||||
|
render: (_: unknown, c: ClienteNaoPositivado) => {
|
||||||
|
if (!c.comprouAntes) {
|
||||||
|
return (
|
||||||
|
<Tag color="default" style={{ borderRadius: 20 }}>
|
||||||
|
nunca comprou
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dias = c.diasSemPedido ?? 999;
|
||||||
|
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
|
||||||
|
{dias}d sem pedido
|
||||||
|
</Tag>
|
||||||
|
{c.ultimoPedido && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{new Date(c.ultimoPedido).toLocaleDateString('pt-BR')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'acoes',
|
||||||
|
width: 96,
|
||||||
|
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||||
|
<Space size={6}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{ borderRadius: 6 }}
|
||||||
|
onClick={() =>
|
||||||
|
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Ficha
|
||||||
|
</Button>
|
||||||
|
{c.whatsapp && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
style={{ borderRadius: 6, background: '#25d366', borderColor: '#25d366' }}
|
||||||
|
icon={<WhatsAppOutlined />}
|
||||||
|
href={waLink(c.whatsapp)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
|
||||||
|
Não positivados no mês
|
||||||
|
{total > 0 && (
|
||||||
|
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
|
||||||
|
{total}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Segmented<OrdemNP>
|
||||||
|
size="small"
|
||||||
|
value={ordem}
|
||||||
|
onChange={setOrdem}
|
||||||
|
options={[
|
||||||
|
{ label: 'Mais longe', value: 'longe' },
|
||||||
|
{ label: 'Compraram antes', value: 'recentes' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{clientes.length === 0 ? (
|
||||||
|
<Text type="secondary">Todos os clientes já positivados este mês. Ótimo trabalho!</Text>
|
||||||
|
) : (
|
||||||
|
<Flex vertical gap={12}>
|
||||||
|
<Table<ClienteNaoPositivado>
|
||||||
|
rowKey="idCliente"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={visible}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
showHeader={false}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
{!expandido && total > PAGE && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ alignSelf: 'flex-start', padding: 0 }}
|
||||||
|
onClick={() => setExpandido(true)}
|
||||||
|
>
|
||||||
|
Ver mais {restante} cliente{restante !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{expandido && total > PAGE && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ alignSelf: 'flex-start', padding: 0 }}
|
||||||
|
onClick={() => setExpandido(false)}
|
||||||
|
>
|
||||||
|
Recolher
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── RepPainel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RepPainel() {
|
export function RepPainel() {
|
||||||
const { data, isLoading } = useRepDashboard();
|
const { data, isLoading } = useRepDashboard();
|
||||||
const { data: user } = useCurrentUser();
|
const { data: user } = useCurrentUser();
|
||||||
@@ -140,13 +321,10 @@ export function RepPainel() {
|
|||||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
<Skeleton active paragraph={{ rows: 2 }} />
|
<Skeleton active paragraph={{ rows: 2 }} />
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={14}>
|
||||||
<Skeleton active />
|
<Skeleton active />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={24} md={10}>
|
||||||
<Skeleton active />
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} md={6}>
|
|
||||||
<Skeleton active />
|
<Skeleton active />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -157,10 +335,10 @@ export function RepPainel() {
|
|||||||
const {
|
const {
|
||||||
meta,
|
meta,
|
||||||
metasPorGrupo = [],
|
metasPorGrupo = [],
|
||||||
comissao,
|
|
||||||
pedidosMes,
|
pedidosMes,
|
||||||
pedidosRecentes = [],
|
pedidosRecentes = [],
|
||||||
clientesInativos = [],
|
naoPositivados = [],
|
||||||
|
totalNaoPositivados = 0,
|
||||||
syncedAt,
|
syncedAt,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
@@ -173,21 +351,21 @@ export function RepPainel() {
|
|||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
{today()}
|
{today()}
|
||||||
{clientesInativos.length > 0 && (
|
{totalNaoPositivados > 0 && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
·{' '}
|
·{' '}
|
||||||
<span style={{ color: 'var(--orange)' }}>
|
<span style={{ color: 'var(--orange)' }}>
|
||||||
{clientesInativos.length} clientes inativos
|
{totalNaoPositivados} clientes sem pedido no mês
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Linha 1 — Meta + KPIs */}
|
{/* Linha 1 — Meta + Pedidos do mês */}
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={14}>
|
||||||
<Card style={{ height: '100%' }}>
|
<Card style={{ height: '100%' }}>
|
||||||
<Flex vertical gap={16}>
|
<Flex vertical gap={16}>
|
||||||
<Flex justify="space-between" align="flex-start">
|
<Flex justify="space-between" align="flex-start">
|
||||||
@@ -228,8 +406,8 @@ export function RepPainel() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={12} md={6}>
|
<Col xs={24} md={10}>
|
||||||
<Card>
|
<Card style={{ height: '100%' }}>
|
||||||
<Space orientation="vertical" size={4}>
|
<Space orientation="vertical" size={4}>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
PEDIDOS NO MÊS
|
PEDIDOS NO MÊS
|
||||||
@@ -238,32 +416,14 @@ export function RepPainel() {
|
|||||||
{pedidosMes}
|
{pedidosMes}
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
<FontAwesomeIcon icon={faArrowTrendUp} /> últimos 30 dias
|
<FontAwesomeIcon icon={faArrowTrendUp} /> mês corrente
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={12} md={6}>
|
|
||||||
<Card>
|
|
||||||
<Space orientation="vertical" size={4}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
|
||||||
COMISSÃO ACUMULADA
|
|
||||||
</Text>
|
|
||||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
|
||||||
{fmt(comissao.total)}
|
|
||||||
</Title>
|
|
||||||
{comissao.flex > 0 && (
|
|
||||||
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
|
|
||||||
FLEX: {fmt(comissao.flex)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Metas por Grupo — acompanhamento multi-medida do mês */}
|
{/* Metas por Grupo */}
|
||||||
{metasPorGrupo.length > 0 && (
|
{metasPorGrupo.length > 0 && (
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
@@ -339,67 +499,13 @@ export function RepPainel() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
|
{/* Linha 2 — Não positivados + Pedidos recentes */}
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={14}>
|
||||||
<Card
|
<NaoPositivados clientes={naoPositivados} total={totalNaoPositivados} />
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
|
|
||||||
Clientes esfriando
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
extra={
|
|
||||||
clientesInativos.length > 0 ? (
|
|
||||||
<Text type="secondary">{clientesInativos.length} clientes</Text>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{clientesInativos.length === 0 ? (
|
|
||||||
<Text type="secondary">Nenhum cliente inativo. Ótimo trabalho!</Text>
|
|
||||||
) : (
|
|
||||||
<Flex vertical gap={12}>
|
|
||||||
{clientesInativos.map((c) => (
|
|
||||||
<Flex
|
|
||||||
key={c.idCliente}
|
|
||||||
justify="space-between"
|
|
||||||
align="center"
|
|
||||||
style={{
|
|
||||||
padding: 'var(--space-sm) var(--space-md)',
|
|
||||||
borderRadius: 12,
|
|
||||||
background: c.diasSemCompra > 60 ? '#fff7e6' : 'var(--bg-surface-alt)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space orientation="vertical" size={0}>
|
|
||||||
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
|
|
||||||
<Text strong>{c.nome}</Text>
|
|
||||||
</Link>
|
|
||||||
{c.ultimaCompraValor && (
|
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
|
||||||
Última compra:{' '}
|
|
||||||
<span className="tabular-nums">
|
|
||||||
{Number(c.ultimaCompraValor).toLocaleString('pt-BR', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'BRL',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
<Tag
|
|
||||||
color={c.diasSemCompra > 60 ? 'orange' : 'default'}
|
|
||||||
className="tabular-nums"
|
|
||||||
>
|
|
||||||
{c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`}
|
|
||||||
</Tag>
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={10}>
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
Button,
|
Card,
|
||||||
|
Col,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Divider,
|
Divider,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
|
Row,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Spin,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import { MailOutlined, PhoneOutlined, WhatsAppOutlined } from '@ant-design/icons';
|
||||||
import { MailOutlined, PhoneOutlined, PlusOutlined, WhatsAppOutlined } from '@ant-design/icons';
|
|
||||||
import type { Contato } from '@sar/api-interface';
|
import type { Contato } from '@sar/api-interface';
|
||||||
import { useClientContacts, useCreateContato } from '../../lib/queries/clients';
|
import { useClientContacts, useCreateContato } from '../../lib/queries/clients';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ function ContactActions({ contato }: { contato: Contato }) {
|
|||||||
const mail = contato.email?.trim();
|
const mail = contato.email?.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space size={6}>
|
<Space size={4} wrap>
|
||||||
{wa && (
|
{wa && (
|
||||||
<Tooltip title={`WhatsApp: ${wa}`}>
|
<Tooltip title={`WhatsApp: ${wa}`}>
|
||||||
<a href={`https://wa.me/55${wa.replace(/\D/g, '')}`} target="_blank" rel="noreferrer">
|
<a href={`https://wa.me/55${wa.replace(/\D/g, '')}`} target="_blank" rel="noreferrer">
|
||||||
@@ -51,7 +51,7 @@ function ContactActions({ contato }: { contato: Contato }) {
|
|||||||
<Tooltip title={mail}>
|
<Tooltip title={mail}>
|
||||||
<a href={`mailto:${mail}`}>
|
<a href={`mailto:${mail}`}>
|
||||||
<Tag icon={<MailOutlined />} color="default" style={{ margin: 0 }}>
|
<Tag icon={<MailOutlined />} color="default" style={{ margin: 0 }}>
|
||||||
{mail.length > 24 ? mail.slice(0, 22) + '…' : mail}
|
{mail.length > 22 ? mail.slice(0, 20) + '…' : mail}
|
||||||
</Tag>
|
</Tag>
|
||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -60,45 +60,18 @@ function ContactActions({ contato }: { contato: Contato }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: TableColumnsType<Contato> = [
|
|
||||||
{
|
|
||||||
title: 'Nome',
|
|
||||||
dataIndex: 'nome',
|
|
||||||
render: (nome: string, r: Contato) => (
|
|
||||||
<div>
|
|
||||||
<Text strong style={{ fontSize: 13 }}>
|
|
||||||
{nome}
|
|
||||||
</Text>
|
|
||||||
{(r.cargo || r.departamento) && (
|
|
||||||
<div>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{[r.cargo, r.departamento].filter(Boolean).join(' · ')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Contato',
|
|
||||||
key: 'contato',
|
|
||||||
render: (_: unknown, r: Contato) => <ContactActions contato={r} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Aniversário',
|
|
||||||
dataIndex: 'dtAniversario',
|
|
||||||
width: 120,
|
|
||||||
render: (v: string | null) => (v ? new Date(v + 'T12:00:00').toLocaleDateString('pt-BR') : '—'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
idCliente: number;
|
idCliente: number;
|
||||||
|
modalOpen?: boolean;
|
||||||
|
onModalClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientContacts({ idCliente }: Props) {
|
export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: Props) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [open, setOpen] = useState(false);
|
const open = modalOpen;
|
||||||
|
const setOpen = (v: boolean) => {
|
||||||
|
if (!v) onModalClose?.();
|
||||||
|
};
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const { data: contacts = [], isLoading } = useClientContacts(idCliente);
|
const { data: contacts = [], isLoading } = useClientContacts(idCliente);
|
||||||
@@ -134,38 +107,53 @@ export function ClientContacts({ idCliente }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<Divider titlePlacement="left" style={{ marginBottom: 12 }}>
|
||||||
style={{
|
Contatos
|
||||||
display: 'flex',
|
</Divider>
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Divider titlePlacement="left" style={{ margin: 0, flex: 1 }}>
|
|
||||||
Contatos
|
|
||||||
</Divider>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
style={{ marginLeft: 16, borderRadius: 6 }}
|
|
||||||
>
|
|
||||||
Adicionar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Table<Contato>
|
{isLoading ? (
|
||||||
rowKey="idContato"
|
<Spin style={{ display: 'block', margin: '16px auto' }} />
|
||||||
columns={columns}
|
) : contacts.length === 0 ? (
|
||||||
dataSource={contacts}
|
<Typography.Text
|
||||||
loading={isLoading}
|
type="secondary"
|
||||||
pagination={false}
|
style={{ display: 'block', marginBottom: 24, textAlign: 'center' }}
|
||||||
size="small"
|
>
|
||||||
locale={{ emptyText: 'Nenhum contato cadastrado.' }}
|
Nenhum contato cadastrado.
|
||||||
style={{ marginBottom: 24 }}
|
</Typography.Text>
|
||||||
/>
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contacts.map((c: Contato) => (
|
||||||
|
<Card
|
||||||
|
key={c.idContato}
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: '10px 12px' } }}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 2 }}>
|
||||||
|
{c.nome}
|
||||||
|
</Text>
|
||||||
|
{(c.cargo || c.departamento) && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 6 }}>
|
||||||
|
{[c.cargo, c.departamento].filter(Boolean).join(' · ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<ContactActions contato={c} />
|
||||||
|
{c.dtAniversario && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>
|
||||||
|
Aniversário: {new Date(c.dtAniversario + 'T12:00:00').toLocaleDateString('pt-BR')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="Novo Contato"
|
title="Novo Contato"
|
||||||
@@ -178,7 +166,7 @@ export function ClientContacts({ idCliente }: Props) {
|
|||||||
confirmLoading={createMutation.isPending}
|
confirmLoading={createMutation.isPending}
|
||||||
okText="Cadastrar"
|
okText="Cadastrar"
|
||||||
cancelText="Cancelar"
|
cancelText="Cancelar"
|
||||||
width={560}
|
width={580}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }} requiredMark="optional">
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }} requiredMark="optional">
|
||||||
@@ -190,40 +178,63 @@ export function ClientContacts({ idCliente }: Props) {
|
|||||||
<Input placeholder="Nome do contato" maxLength={100} />
|
<Input placeholder="Nome do contato" maxLength={100} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="cargo" label="Cargo">
|
<Row gutter={12}>
|
||||||
<Input placeholder="Comprador, Gerente..." maxLength={100} />
|
<Col span={12}>
|
||||||
</Form.Item>
|
<Form.Item name="cargo" label="Cargo">
|
||||||
|
<Input placeholder="Comprador, Gerente..." maxLength={100} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="departamento" label="Departamento">
|
||||||
|
<Input placeholder="Compras, Financeiro..." maxLength={100} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Form.Item name="departamento" label="Departamento">
|
<Row gutter={12}>
|
||||||
<Input placeholder="Compras, Financeiro..." maxLength={100} />
|
<Col span={12}>
|
||||||
</Form.Item>
|
<Form.Item name="whatsapp" label="WhatsApp">
|
||||||
|
<Input
|
||||||
|
placeholder="48999990000"
|
||||||
|
maxLength={20}
|
||||||
|
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="celular" label="Celular">
|
||||||
|
<Input placeholder="48999990000" maxLength={20} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Form.Item name="whatsapp" label="WhatsApp">
|
<Row gutter={12}>
|
||||||
<Input
|
<Col span={12}>
|
||||||
placeholder="48999990000"
|
<Form.Item name="telefone" label="Telefone fixo">
|
||||||
maxLength={20}
|
<Input placeholder="4833330000" maxLength={20} />
|
||||||
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
|
</Form.Item>
|
||||||
/>
|
</Col>
|
||||||
</Form.Item>
|
<Col span={12}>
|
||||||
|
<Form.Item name="email" label="E-mail">
|
||||||
|
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Form.Item name="celular" label="Celular">
|
<Row gutter={12}>
|
||||||
<Input placeholder="48999990000" maxLength={20} />
|
<Col span={12}>
|
||||||
</Form.Item>
|
<Form.Item name="dtAniversario" label="Aniversário">
|
||||||
|
<DatePicker
|
||||||
<Form.Item name="telefone" label="Telefone fixo">
|
format="DD/MM/YYYY"
|
||||||
<Input placeholder="4833330000" maxLength={20} />
|
style={{ width: '100%' }}
|
||||||
</Form.Item>
|
placeholder="dd/mm/aaaa"
|
||||||
|
/>
|
||||||
<Form.Item name="email" label="E-mail">
|
</Form.Item>
|
||||||
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
|
</Col>
|
||||||
</Form.Item>
|
</Row>
|
||||||
|
|
||||||
<Form.Item name="dtAniversario" label="Aniversário">
|
|
||||||
<DatePicker format="DD/MM/YYYY" style={{ width: '100%' }} placeholder="dd/mm/aaaa" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="anotacoes" label="Anotações">
|
<Form.Item name="anotacoes" label="Anotações">
|
||||||
<Input.TextArea rows={3} placeholder="Observações sobre o contato..." />
|
<Input.TextArea rows={2} placeholder="Observações sobre o contato..." />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
useOfflineSync();
|
useOfflineSync();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
|
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
|
||||||
{!isOnline && (
|
{!isOnline && (
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -35,14 +35,15 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
|
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
|
||||||
<Flex flex={1}>
|
<Flex flex={1} style={{ overflow: 'hidden' }}>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 'var(--space-xl)',
|
padding: 'var(--space-xl)',
|
||||||
overflow: 'auto',
|
overflowY: 'auto',
|
||||||
maxWidth: '100%',
|
overflowX: 'hidden',
|
||||||
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import {
|
|||||||
faClipboardList,
|
faClipboardList,
|
||||||
faClipboardCheck,
|
faClipboardCheck,
|
||||||
faFunnelDollar,
|
faFunnelDollar,
|
||||||
faCalendarDays,
|
|
||||||
faUsers,
|
faUsers,
|
||||||
faBoxesStacked,
|
faBoxesStacked,
|
||||||
faGear,
|
|
||||||
faPercent,
|
|
||||||
faFileInvoiceDollar,
|
faFileInvoiceDollar,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import type { ItemType } from 'antd/es/menu/interface';
|
import type { ItemType } from 'antd/es/menu/interface';
|
||||||
@@ -28,11 +25,6 @@ export function Sidebar() {
|
|||||||
icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />,
|
||||||
label: 'Painel',
|
label: 'Painel',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: '/agenda',
|
|
||||||
icon: <FontAwesomeIcon icon={faCalendarDays} fixedWidth />,
|
|
||||||
label: 'Agenda e Rotas',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: '/clientes',
|
key: '/clientes',
|
||||||
icon: <FontAwesomeIcon icon={faUsers} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faUsers} fixedWidth />,
|
||||||
@@ -58,24 +50,11 @@ export function Sidebar() {
|
|||||||
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
||||||
label: 'Consulta ERP',
|
label: 'Consulta ERP',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: '/comissao',
|
|
||||||
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
|
|
||||||
label: 'Comissão / FLEX',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'divider',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: '/relatorios',
|
key: '/relatorios',
|
||||||
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
||||||
label: 'Relatórios',
|
label: 'Relatórios',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: '/configuracoes',
|
|
||||||
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
|
|
||||||
label: 'Configurações',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,14 +5,24 @@ import {
|
|||||||
ClientNovoResultSchema,
|
ClientNovoResultSchema,
|
||||||
ContatoResultSchema,
|
ContatoResultSchema,
|
||||||
ContatoSchema,
|
ContatoSchema,
|
||||||
|
CtrSummarySchema,
|
||||||
|
CtrTituloSchema,
|
||||||
|
NotaFiscalSchema,
|
||||||
|
PedidoSummarySchema,
|
||||||
|
TopProdutoSchema,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
type ClientListQuery,
|
type ClientListQuery,
|
||||||
type ClientListResponse,
|
type ClientListResponse,
|
||||||
type ClientNovoResult,
|
type ClientNovoResult,
|
||||||
type Contato,
|
type Contato,
|
||||||
type ContatoResult,
|
type ContatoResult,
|
||||||
|
type CtrSummary,
|
||||||
|
type CtrTitulo,
|
||||||
type CreateClientNovo,
|
type CreateClientNovo,
|
||||||
type CreateContato,
|
type CreateContato,
|
||||||
|
type NotaFiscal,
|
||||||
|
type PedidoSummary,
|
||||||
|
type TopProduto,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
@@ -52,6 +62,61 @@ export function useClientDetail(id: number | string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useClientTopProdutos(idCliente: number | undefined, limit = 30) {
|
||||||
|
return useQuery<TopProduto[]>({
|
||||||
|
queryKey: ['clients', 'top-produtos', idCliente, limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/clients/${idCliente}/top-produtos?limit=${limit}`);
|
||||||
|
return z.array(TopProdutoSchema).parse(res);
|
||||||
|
},
|
||||||
|
enabled: !!idCliente,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientOrdersHistory(idCliente: number | undefined, limit = 50) {
|
||||||
|
return useQuery<PedidoSummary[]>({
|
||||||
|
queryKey: ['clients', 'orders-history', idCliente, limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/clients/${idCliente}/orders-history?limit=${limit}`);
|
||||||
|
return z.array(PedidoSummarySchema).parse(res);
|
||||||
|
},
|
||||||
|
enabled: !!idCliente,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientCtrList(idCliente: number | undefined, limit = 60) {
|
||||||
|
return useQuery<CtrTitulo[]>({
|
||||||
|
queryKey: ['clients', 'ctr-list', idCliente, limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/clients/${idCliente}/ctr-list?limit=${limit}`);
|
||||||
|
return z.array(CtrTituloSchema).parse(res);
|
||||||
|
},
|
||||||
|
enabled: !!idCliente,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientNotasFiscais(idCliente: number | undefined, limit = 30) {
|
||||||
|
return useQuery<NotaFiscal[]>({
|
||||||
|
queryKey: ['clients', 'notas', idCliente, limit],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/clients/${idCliente}/notas?limit=${limit}`);
|
||||||
|
return z.array(NotaFiscalSchema).parse(res);
|
||||||
|
},
|
||||||
|
enabled: !!idCliente,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientCtr(idCliente: number | undefined) {
|
||||||
|
return useQuery<CtrSummary>({
|
||||||
|
queryKey: ['clients', 'ctr', idCliente],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/clients/${idCliente}/ctr`);
|
||||||
|
return CtrSummarySchema.parse(res);
|
||||||
|
},
|
||||||
|
enabled: !!idCliente,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useClientContacts(idCliente: number | undefined) {
|
export function useClientContacts(idCliente: number | undefined) {
|
||||||
return useQuery<Contato[]>({
|
return useQuery<Contato[]>({
|
||||||
queryKey: ['clients', 'contacts', idCliente],
|
queryKey: ['clients', 'contacts', idCliente],
|
||||||
|
|||||||
37
apps/web/src/lib/queries/reports.ts
Normal file
37
apps/web/src/lib/queries/reports.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ReportMetaResponseSchema,
|
||||||
|
ReportCarteiraResponseSchema,
|
||||||
|
ReportAbcResponseSchema,
|
||||||
|
type ReportMetaQuery,
|
||||||
|
type ReportAbcQuery,
|
||||||
|
type ReportMetaResponse,
|
||||||
|
type ReportCarteiraResponse,
|
||||||
|
type ReportAbcResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
export function useReportMeta(params: ReportMetaQuery) {
|
||||||
|
const qs = new URLSearchParams({ mes: String(params.mes), ano: String(params.ano) });
|
||||||
|
return useQuery<ReportMetaResponse>({
|
||||||
|
queryKey: ['reports', 'meta', params],
|
||||||
|
queryFn: async () => ReportMetaResponseSchema.parse(await apiFetch(`/reports/meta?${qs}`)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportCarteira() {
|
||||||
|
return useQuery<ReportCarteiraResponse>({
|
||||||
|
queryKey: ['reports', 'carteira'],
|
||||||
|
queryFn: async () => ReportCarteiraResponseSchema.parse(await apiFetch('/reports/carteira')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportAbc(params: ReportAbcQuery) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.mes != null) qs.set('mes', String(params.mes));
|
||||||
|
if (params.ano != null) qs.set('ano', String(params.ano));
|
||||||
|
return useQuery<ReportAbcResponse>({
|
||||||
|
queryKey: ['reports', 'abc', params],
|
||||||
|
queryFn: async () => ReportAbcResponseSchema.parse(await apiFetch(`/reports/abc?${qs}`)),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { NewClientPage } from '../cockpits/rep/NewClientPage';
|
|||||||
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
||||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||||
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
||||||
|
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
||||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||||
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
||||||
import { authStore } from './auth-store';
|
import { authStore } from './auth-store';
|
||||||
@@ -125,6 +126,12 @@ const pedidosErpRoute = createRoute({
|
|||||||
component: OrdersErpPage,
|
component: OrdersErpPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const relatoriosRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/relatorios',
|
||||||
|
component: RelatoriosPage,
|
||||||
|
});
|
||||||
|
|
||||||
const aprovacoes = createRoute({
|
const aprovacoes = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/aprovacoes',
|
path: '/aprovacoes',
|
||||||
@@ -143,6 +150,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
pedidoPrintRoute,
|
pedidoPrintRoute,
|
||||||
catalogoRoute,
|
catalogoRoute,
|
||||||
pedidosErpRoute,
|
pedidosErpRoute,
|
||||||
|
relatoriosRoute,
|
||||||
aprovacoes,
|
aprovacoes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
265
docs/plano-migracao-modelo-canonico.md
Normal file
265
docs/plano-migracao-modelo-canonico.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# Plano de Migração — Modelo de Dados Canônico SAR
|
||||||
|
|
||||||
|
**Data:** 2026-06-22
|
||||||
|
**Autor:** Julian (Product Owner SAR)
|
||||||
|
**Status:** Proposta para avaliação
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexto e Problema
|
||||||
|
|
||||||
|
### Situação atual
|
||||||
|
|
||||||
|
O SAR foi iniciado consultando diretamente as **views do ERP JCS** (`vw_clientes`, `vw_pedidos_erp`, `vw_produtos`, etc.) que existem dentro do banco de dados do cliente (`libreplast@192.168.0.43`). Isso funcionou para o cliente piloto, mas cria uma dependência estrutural que inviabiliza a expansão do produto.
|
||||||
|
|
||||||
|
### Problema identificado
|
||||||
|
|
||||||
|
| Sintoma | Consequência |
|
||||||
|
|---|---|
|
||||||
|
| SAR acessa views do ERP via SQL raw | Cada novo ERP exige reescrever queries |
|
||||||
|
| Schema `sar` vive *dentro* do banco ERP | Sem isolamento; SAR depende de acesso ao servidor do cliente |
|
||||||
|
| Views são específicas do ERP JCS | ERP diferente = produto diferente |
|
||||||
|
| Sem camada de abstração | Impossível oferecer o SAR como SaaS multi-tenant real |
|
||||||
|
|
||||||
|
### Diagnóstico técnico (levantamento 2026-06-22)
|
||||||
|
|
||||||
|
```
|
||||||
|
Módulos 100% acoplados ao ERP: auth, catalog, clients
|
||||||
|
Módulos parcialmente acoplados: orders (leitura), dashboard
|
||||||
|
Módulos já independentes: notifications, workspace
|
||||||
|
Views ERP consumidas: 11 views + 1 tabela direta
|
||||||
|
Tabelas próprias SAR existentes: 6 models Prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Solução Proposta
|
||||||
|
|
||||||
|
### Modelo Canônico + Connectors
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ APP SAR │
|
||||||
|
│ (frontend + API NestJS) │
|
||||||
|
│ Lê APENAS tabelas próprias do SAR │
|
||||||
|
└──────────────────────┬──────────────────────────────┘
|
||||||
|
│ Prisma ORM
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Banco de Dados SAR │
|
||||||
|
│ (PostgreSQL isolado por workspace) │
|
||||||
|
│ tabelas canônicas: clientes, produtos, pedidos... │
|
||||||
|
└──────────┬──────────────────────┬───────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ Connector │ │ Connector │
|
||||||
|
│ ERP JCS │ │ ERP X (futuro) │
|
||||||
|
│ (PostgreSQL) │ │ (REST API / CSV / outro) │
|
||||||
|
└──────────────────┘ └──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Princípio:** O ERP é uma **fonte de dados**, não o sistema de registro do SAR. Cada cliente tem um connector específico para seu ERP. O SAR opera exclusivamente sobre seu próprio modelo de dados.
|
||||||
|
|
||||||
|
### O que não muda
|
||||||
|
|
||||||
|
Os dados transacionais do SAR já estão corretos e **permanecem sem alteração**:
|
||||||
|
- `pedidos`, `pedido_itens`, `historico_pedido`
|
||||||
|
- `alcada_desconto`, `meta_representante`
|
||||||
|
- `push_subscription`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tabelas Canônicas a Criar
|
||||||
|
|
||||||
|
### 3.1 Entidades mestres (sincronizadas do ERP)
|
||||||
|
|
||||||
|
| Tabela SAR | Substitui | Campos principais |
|
||||||
|
|---|---|---|
|
||||||
|
| `sar.clientes` | `vw_clientes` | codigo, razao_social, nome_fantasia, cnpj, cidade, uf, ativo, representante_codigo |
|
||||||
|
| `sar.representantes` | `vw_representantes` | codigo, nome, email, senha_hash, tipo (rep/supervisor), ativo |
|
||||||
|
| `sar.produtos` | `vw_produtos` | codigo, descricao, unidade, ncm, ativo |
|
||||||
|
| `sar.estoques` | `vw_estoque` | produto_codigo, deposito, saldo (snapshot com timestamp) |
|
||||||
|
| `sar.pautas` | `vw_pautas` | codigo, descricao, ativa |
|
||||||
|
| `sar.pauta_produtos` | `vw_pauta_produtos` | pauta_codigo, produto_codigo, preco, desconto_max |
|
||||||
|
| `sar.formas_pagamento` | `vw_formas_pagamento` | codigo, descricao, parcelas, ativa |
|
||||||
|
| `sar.municipios` | `vw_municipios` | ibge, nome, uf |
|
||||||
|
| `sar.empresa` | `gestao.empresa` | cnpj, razao_social, nome_fantasia, endereco, logo_url |
|
||||||
|
|
||||||
|
### 3.2 Histórico ERP (read-only, sincronizado)
|
||||||
|
|
||||||
|
| Tabela SAR | Substitui | Propósito |
|
||||||
|
|---|---|---|
|
||||||
|
| `sar.pedidos_erp` | `vw_pedidos_erp` | Histórico de pedidos anteriores ao SAR |
|
||||||
|
| `sar.pedido_itens_erp` | `vw_peditens_erp` | Itens dos pedidos ERP |
|
||||||
|
|
||||||
|
### 3.3 Controle de sincronização
|
||||||
|
|
||||||
|
| Tabela SAR | Propósito |
|
||||||
|
|---|---|
|
||||||
|
| `sar.sync_log` | Registro de cada execução de sync (início, fim, erros, registros processados) |
|
||||||
|
| `sar.sync_config` | Configuração do connector por workspace (tipo ERP, credenciais, frequência) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Plano de Execução por Fases
|
||||||
|
|
||||||
|
### Fase 0 — Banco de Dados Isolado `[Fundação]`
|
||||||
|
|
||||||
|
**Objetivo:** Separar o banco SAR do banco ERP do cliente.
|
||||||
|
|
||||||
|
**Ações:**
|
||||||
|
- Provisionar banco PostgreSQL exclusivo para o SAR (`sar_db`)
|
||||||
|
- Configurar variável `DATABASE_URL` apontando para o novo banco
|
||||||
|
- Manter `ERP_DB_*` apenas no connector (não no app principal)
|
||||||
|
- Estrutura multi-tenant: um schema por workspace ou banco por workspace (já previsto na stack)
|
||||||
|
|
||||||
|
**Resultado:** SAR pode funcionar mesmo sem acesso ao ERP; connector roda separado.
|
||||||
|
|
||||||
|
**Estimativa:** 2–3 dias
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 1 — Schema Canônico `[Estrutura]`
|
||||||
|
|
||||||
|
**Objetivo:** Criar todos os models Prisma das tabelas canônicas.
|
||||||
|
|
||||||
|
**Ações:**
|
||||||
|
- Adicionar 11 models ao `schema.prisma` (seção 3.1 e 3.2)
|
||||||
|
- Adicionar 2 models de controle de sync (seção 3.3)
|
||||||
|
- Gerar e aplicar migrations
|
||||||
|
- Nenhuma mudança no código da API ainda
|
||||||
|
|
||||||
|
**Resultado:** Tabelas existem no banco, prontas para receber dados.
|
||||||
|
|
||||||
|
**Estimativa:** 3–4 dias
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 2 — Sync Service (Connector ERP JCS) `[Motor]`
|
||||||
|
|
||||||
|
**Objetivo:** Criar a camada que lê o ERP e popula as tabelas canônicas.
|
||||||
|
|
||||||
|
**Ações:**
|
||||||
|
- Criar módulo `SyncModule` no NestJS
|
||||||
|
- Implementar `ErpJcsConnector` com leitura das 11 views existentes
|
||||||
|
- Sync completo na inicialização do workspace
|
||||||
|
- Sync incremental por cron (ex: a cada 5 minutos para estoque, 30 min para cadastros)
|
||||||
|
- Registrar execuções em `sync_log`
|
||||||
|
- Endpoint admin `/api/v1/sync/trigger` para forçar sync manual
|
||||||
|
|
||||||
|
**Estratégia de sync:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────┐
|
||||||
|
│ Frequências recomendadas │
|
||||||
|
│ │
|
||||||
|
│ A cada 5 min: estoques (alta volatilidade) │
|
||||||
|
│ A cada 30 min: produtos, pautas, preços │
|
||||||
|
│ A cada 60 min: clientes, representantes │
|
||||||
|
│ A cada 6h: pedidos_erp (histórico) │
|
||||||
|
│ 1x por dia: municipios, empresa, formas_pgto │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultado:** Tabelas SAR ficam populadas e atualizadas automaticamente.
|
||||||
|
|
||||||
|
**Estimativa:** 5–8 dias
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 3 — Migração dos Módulos `[Execução]`
|
||||||
|
|
||||||
|
**Objetivo:** Substituir todos os `$queryRaw` por queries Prisma nas tabelas canônicas.
|
||||||
|
|
||||||
|
**Prioridade e ordem:**
|
||||||
|
|
||||||
|
| Ordem | Módulo | Complexidade | Motivo da prioridade |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `catalog` | Baixa | Somente leitura, sem lógica de negócio complexa |
|
||||||
|
| 2 | `clients` | Média | Base do dashboard e pedidos |
|
||||||
|
| 3 | `auth` | Baixa | Bem delimitado; lê apenas `representantes` |
|
||||||
|
| 4 | `dashboard` | Média | Depende de clients migrado |
|
||||||
|
| 5 | `orders` (leitura) | Média | Parte escrita já usa Prisma corretamente |
|
||||||
|
|
||||||
|
**Processo por módulo:**
|
||||||
|
1. Com o sync rodando, os dados já estão nas tabelas canônicas
|
||||||
|
2. Substituir `$queryRaw` por `prisma.cliente.findMany(...)` etc.
|
||||||
|
3. Manter o contrato de resposta da API (frontend não muda)
|
||||||
|
4. Teste comparativo: mesmo resultado via ERP vs. via tabela SAR
|
||||||
|
5. Remover a query antiga após validação
|
||||||
|
|
||||||
|
**Resultado:** Zero dependência de views ERP no código da API.
|
||||||
|
|
||||||
|
**Estimativa:** 5–8 dias
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 4 — Segundo Connector (Validação da Abstração) `[Prova]`
|
||||||
|
|
||||||
|
**Objetivo:** Onboarding de um cliente com ERP diferente do JCS.
|
||||||
|
|
||||||
|
**Ações:**
|
||||||
|
- Identificar segundo ERP (ex: TOTVS, Sankhya, outro)
|
||||||
|
- Criar `ErpXConnector` implementando a mesma interface do `ErpJcsConnector`
|
||||||
|
- Mapear campos do ERP X para o schema canônico SAR
|
||||||
|
- Sem nenhuma mudança no app SAR
|
||||||
|
|
||||||
|
**Resultado:** Prova de conceito real de que a arquitetura é ERP-agnóstica.
|
||||||
|
|
||||||
|
**Estimativa:** 4–6 dias (variável conforme ERP)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Cronograma Estimado
|
||||||
|
|
||||||
|
```
|
||||||
|
Semana 1: Fase 0 (banco isolado) + Fase 1 (schema Prisma)
|
||||||
|
Semana 2: Fase 2 (Sync Service — maior esforço)
|
||||||
|
Semana 3: Fase 3 (migração dos módulos)
|
||||||
|
Semana 4: Fase 4 (segundo connector) + testes + documentação
|
||||||
|
|
||||||
|
Total estimado: 4 semanas (19–25 dias úteis)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Riscos e Mitigações
|
||||||
|
|
||||||
|
| Risco | Probabilidade | Impacto | Mitigação |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Divergência de dados entre ERP e tabela SAR durante sync | Médio | Alto | Sync comparativo com log de diferenças; alertas |
|
||||||
|
| Latência do sync afeta experiência do rep | Baixo | Médio | Sync de estoque a cada 5 min é suficiente para força de vendas |
|
||||||
|
| ERP do cliente sem conectividade temporária | Médio | Baixo | SAR continua funcionando com dados do último sync |
|
||||||
|
| Campos do segundo ERP sem equivalência no schema canônico | Médio | Médio | Schema canônico flexível com campo `metadata jsonb` por entidade |
|
||||||
|
| Custo de manutenção de dois bancos | Baixo | Baixo | Banco SAR é pequeno; ERP continua como está |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Benefícios Esperados
|
||||||
|
|
||||||
|
| Benefício | Impacto |
|
||||||
|
|---|---|
|
||||||
|
| **Multi-ERP real** | Onboarding de qualquer cliente independente do ERP |
|
||||||
|
| **Isolamento de falhas** | Queda do ERP não derruba o SAR |
|
||||||
|
| **Performance** | Queries no banco SAR são mais rápidas (schema otimizado) |
|
||||||
|
| **Funciona offline** | Rep consulta dados mesmo sem VPN/internet no cliente |
|
||||||
|
| **Multi-tenant limpo** | Banco SAR separado por workspace desde o início |
|
||||||
|
| **Evolução independente** | SAR pode adicionar campos sem depender do ERP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Decisão
|
||||||
|
|
||||||
|
O projeto SAR **não precisa ser refeito do zero**. A infraestrutura (NestJS, autenticação, frontend React, ciclo de pedidos, alçadas, metas, push notifications) está correta e é reaproveitada integralmente.
|
||||||
|
|
||||||
|
A mudança é **cirúrgica e incremental**:
|
||||||
|
- Adicionar banco e tabelas canônicas (Fases 0 e 1)
|
||||||
|
- Construir o motor de sync (Fase 2)
|
||||||
|
- Substituir queries raw módulo a módulo (Fase 3)
|
||||||
|
|
||||||
|
O risco de **não fazer** essa migração agora é maior: quanto mais o produto crescer sobre as views do ERP JCS, mais caro fica refatorar depois.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento gerado em 2026-06-22. Para dúvidas: jcsinfo@gmail.com*
|
||||||
@@ -6,3 +6,4 @@ export * from './lib/product.contract';
|
|||||||
export * from './lib/dashboard.contract';
|
export * from './lib/dashboard.contract';
|
||||||
export * from './lib/notifications.contract';
|
export * from './lib/notifications.contract';
|
||||||
export * from './lib/company.contract';
|
export * from './lib/company.contract';
|
||||||
|
export * from './lib/report.contract';
|
||||||
|
|||||||
@@ -111,6 +111,54 @@ export const ContatoResultSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type ContatoResult = z.infer<typeof ContatoResultSchema>;
|
export type ContatoResult = z.infer<typeof ContatoResultSchema>;
|
||||||
|
|
||||||
|
// ─── Produtos mais comprados por cliente ─────────────────────────────────────
|
||||||
|
|
||||||
|
export const TopProdutoSchema = z.object({
|
||||||
|
idProduto: z.number().int(),
|
||||||
|
descricao: z.string(),
|
||||||
|
unidade: z.string().nullable(),
|
||||||
|
qtdTotal: z.number(),
|
||||||
|
numPedidos: z.number().int(),
|
||||||
|
ultimaCompra: z.string(),
|
||||||
|
ultimoPreco: z.number(),
|
||||||
|
});
|
||||||
|
export type TopProduto = z.infer<typeof TopProdutoSchema>;
|
||||||
|
|
||||||
|
// ─── Contas a Receber (resumo por cliente) ───────────────────────────────────
|
||||||
|
|
||||||
|
export const CtrSummarySchema = z.object({
|
||||||
|
qtdAberto: z.number().int(),
|
||||||
|
totalAberto: z.number(),
|
||||||
|
qtdVencido: z.number().int(),
|
||||||
|
totalVencido: z.number(),
|
||||||
|
maiorAtrasoDias: z.number().int(),
|
||||||
|
});
|
||||||
|
export type CtrSummary = z.infer<typeof CtrSummarySchema>;
|
||||||
|
|
||||||
|
// ─── Contas a Receber (lista de títulos em aberto) ────────────────────────────
|
||||||
|
|
||||||
|
export const CtrTituloSchema = z.object({
|
||||||
|
idCtr: z.number().int(),
|
||||||
|
numero: z.string(),
|
||||||
|
prefixo: z.string(),
|
||||||
|
dtEmissao: z.string(),
|
||||||
|
dtVencimento: z.string(),
|
||||||
|
saldo: z.number(),
|
||||||
|
});
|
||||||
|
export type CtrTitulo = z.infer<typeof CtrTituloSchema>;
|
||||||
|
|
||||||
|
// ─── Notas Fiscais por cliente ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const NotaFiscalSchema = z.object({
|
||||||
|
idNf: z.number().int(),
|
||||||
|
numero: z.number().int(),
|
||||||
|
serie: z.string(),
|
||||||
|
dtEmissao: z.string(),
|
||||||
|
vlNf: z.number(),
|
||||||
|
chaveAcessoNfe: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type NotaFiscal = z.infer<typeof NotaFiscalSchema>;
|
||||||
|
|
||||||
// ─── Municipio ───────────────────────────────────────────────────────────────
|
// ─── Municipio ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const MunicipioSchema = z.object({
|
export const MunicipioSchema = z.object({
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import { PedidoSummarySchema } from './order.contract';
|
|||||||
|
|
||||||
// ADR 0006 revogado: OrderSummary → PedidoSummary, ids numéricos.
|
// ADR 0006 revogado: OrderSummary → PedidoSummary, ids numéricos.
|
||||||
|
|
||||||
export const ClienteInativoSchema = z.object({
|
export const ClienteNaoPositivadoSchema = z.object({
|
||||||
idCliente: z.number().int(),
|
idCliente: z.number().int(),
|
||||||
nome: z.string(),
|
nome: z.string(),
|
||||||
diasSemCompra: z.number().int(),
|
razao: z.string().nullable(),
|
||||||
ultimaCompraValor: z.string().nullable(),
|
diasSemPedido: z.number().int(), // 999 = nunca comprou
|
||||||
|
ultimoPedido: z.string().nullable(), // ISO date YYYY-MM-DD
|
||||||
|
comprouAntes: z.boolean(),
|
||||||
|
whatsapp: z.string().nullable(),
|
||||||
});
|
});
|
||||||
export type ClienteInativo = z.infer<typeof ClienteInativoSchema>;
|
export type ClienteNaoPositivado = z.infer<typeof ClienteNaoPositivadoSchema>;
|
||||||
|
|
||||||
// Dimensão de meta. O ERP (vw_metas.tipo) define como o cliente acompanha metas:
|
// Dimensão de meta. O ERP (vw_metas.tipo) define como o cliente acompanha metas:
|
||||||
// GL = global, GR = por grupo. Motor único; outras dimensões (marca/subgrupo/
|
// GL = global, GR = por grupo. Motor único; outras dimensões (marca/subgrupo/
|
||||||
@@ -22,7 +25,7 @@ export type MetaDimensao = z.infer<typeof MetaDimensaoSchema>;
|
|||||||
export const MetaItemSchema = z.object({
|
export const MetaItemSchema = z.object({
|
||||||
codigo: z.number().int().nullable(),
|
codigo: z.number().int().nullable(),
|
||||||
rotulo: z.string(),
|
rotulo: z.string(),
|
||||||
pedidos: z.number().int(), // qtd de pedidos faturados no grupo (realizado)
|
pedidos: z.number().int(),
|
||||||
valorMeta: z.number(),
|
valorMeta: z.number(),
|
||||||
valorReal: z.number(),
|
valorReal: z.number(),
|
||||||
qtdMeta: z.number(),
|
qtdMeta: z.number(),
|
||||||
@@ -31,8 +34,8 @@ export const MetaItemSchema = z.object({
|
|||||||
pesoReal: z.number(),
|
pesoReal: z.number(),
|
||||||
fatorMeta: z.number(),
|
fatorMeta: z.number(),
|
||||||
fatorReal: z.number(),
|
fatorReal: z.number(),
|
||||||
pct: z.number(), // % de valor (real/meta) — base da barra de progresso
|
pct: z.number(),
|
||||||
falta: z.number(), // valor faltante p/ a meta
|
falta: z.number(),
|
||||||
});
|
});
|
||||||
export type MetaItem = z.infer<typeof MetaItemSchema>;
|
export type MetaItem = z.infer<typeof MetaItemSchema>;
|
||||||
|
|
||||||
@@ -43,17 +46,12 @@ export const RepDashboardSchema = z.object({
|
|||||||
pct: z.number(),
|
pct: z.number(),
|
||||||
falta: z.number(),
|
falta: z.number(),
|
||||||
}),
|
}),
|
||||||
// Dimensão detectada do ERP e detalhamento por grupo (vazio quando global).
|
|
||||||
metaDimensao: MetaDimensaoSchema.default('global'),
|
metaDimensao: MetaDimensaoSchema.default('global'),
|
||||||
metasPorGrupo: z.array(MetaItemSchema).default([]),
|
metasPorGrupo: z.array(MetaItemSchema).default([]),
|
||||||
comissao: z.object({
|
|
||||||
fixa: z.number(),
|
|
||||||
flex: z.number(),
|
|
||||||
total: z.number(),
|
|
||||||
}),
|
|
||||||
pedidosMes: z.number().int(),
|
pedidosMes: z.number().int(),
|
||||||
pedidosRecentes: z.array(PedidoSummarySchema),
|
pedidosRecentes: z.array(PedidoSummarySchema),
|
||||||
clientesInativos: z.array(ClienteInativoSchema),
|
naoPositivados: z.array(ClienteNaoPositivadoSchema),
|
||||||
|
totalNaoPositivados: z.number().int(),
|
||||||
syncedAt: z.iso.datetime(),
|
syncedAt: z.iso.datetime(),
|
||||||
});
|
});
|
||||||
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
||||||
|
|||||||
70
libs/shared/api-interface/src/lib/report.contract.ts
Normal file
70
libs/shared/api-interface/src/lib/report.contract.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ─── Desempenho vs. Meta ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ReportMetaQuerySchema = z.object({
|
||||||
|
mes: z.coerce.number().int().min(1).max(12),
|
||||||
|
ano: z.coerce.number().int().min(2000).max(2100),
|
||||||
|
});
|
||||||
|
export type ReportMetaQuery = z.infer<typeof ReportMetaQuerySchema>;
|
||||||
|
|
||||||
|
export const ReportMetaResponseSchema = z.object({
|
||||||
|
mes: z.number(),
|
||||||
|
ano: z.number(),
|
||||||
|
realizado: z.string(),
|
||||||
|
meta: z.string(),
|
||||||
|
percentual: z.string(),
|
||||||
|
comissaoEstimada: z.string(),
|
||||||
|
totalPedidos: z.number(),
|
||||||
|
});
|
||||||
|
export type ReportMetaResponse = z.infer<typeof ReportMetaResponseSchema>;
|
||||||
|
|
||||||
|
// ─── Carteira de Clientes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CarteiraClienteSchema = z.object({
|
||||||
|
idCliente: z.number(),
|
||||||
|
nome: z.string().nullable(),
|
||||||
|
razao: z.string().nullable(),
|
||||||
|
ativo: z.number(),
|
||||||
|
ultimoPedido: z.string().nullable(),
|
||||||
|
diasSemPedido: z.number().nullable(),
|
||||||
|
totalPedidos: z.number(),
|
||||||
|
ticketMedio: z.string(),
|
||||||
|
});
|
||||||
|
export type CarteiraCliente = z.infer<typeof CarteiraClienteSchema>;
|
||||||
|
|
||||||
|
export const ReportCarteiraResponseSchema = z.object({
|
||||||
|
data: z.array(CarteiraClienteSchema),
|
||||||
|
total: z.number(),
|
||||||
|
inativos30: z.number(),
|
||||||
|
inativos60: z.number(),
|
||||||
|
semPedido: z.number(),
|
||||||
|
});
|
||||||
|
export type ReportCarteiraResponse = z.infer<typeof ReportCarteiraResponseSchema>;
|
||||||
|
|
||||||
|
// ─── Curva ABC de Produtos ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ReportAbcQuerySchema = z.object({
|
||||||
|
mes: z.coerce.number().int().min(1).max(12).optional(),
|
||||||
|
ano: z.coerce.number().int().min(2000).max(2100).optional(),
|
||||||
|
});
|
||||||
|
export type ReportAbcQuery = z.infer<typeof ReportAbcQuerySchema>;
|
||||||
|
|
||||||
|
export const AbcProdutoSchema = z.object({
|
||||||
|
codProduto: z.string().nullable(),
|
||||||
|
descProduto: z.string(),
|
||||||
|
totalFaturado: z.string(),
|
||||||
|
qtdVendida: z.string(),
|
||||||
|
participacao: z.string(),
|
||||||
|
curva: z.enum(['A', 'B', 'C']),
|
||||||
|
descontoMedio: z.string(),
|
||||||
|
comissaoTotal: z.string(),
|
||||||
|
});
|
||||||
|
export type AbcProduto = z.infer<typeof AbcProdutoSchema>;
|
||||||
|
|
||||||
|
export const ReportAbcResponseSchema = z.object({
|
||||||
|
data: z.array(AbcProdutoSchema),
|
||||||
|
totalFaturado: z.string(),
|
||||||
|
periodo: z.string(),
|
||||||
|
});
|
||||||
|
export type ReportAbcResponse = z.infer<typeof ReportAbcResponseSchema>;
|
||||||
@@ -505,14 +505,18 @@ FROM gestao.marca;
|
|||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- Contatos vinculados a clientes (gestao.contato)
|
-- Contatos vinculados a clientes (gestao.contato)
|
||||||
-- id_entidade = id_corrent::text (CHAR 20, auto-pad)
|
-- O ERP grava id_entidade como 'COR#<id_corrent>' (formato nativo).
|
||||||
-- Usado para envio via WhatsApp / ChatWoot futuramente
|
-- O SAR legado gravava apenas o número puro. Ambos os formatos são aceitos.
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE OR REPLACE VIEW sar.vw_contatos AS
|
CREATE OR REPLACE VIEW sar.vw_contatos AS
|
||||||
SELECT
|
SELECT
|
||||||
c.id_contato,
|
c.id_contato,
|
||||||
c.id_empresa,
|
c.id_empresa,
|
||||||
TRIM(c.id_entidade)::integer AS id_corrent,
|
CASE
|
||||||
|
WHEN TRIM(c.id_entidade) ~ '^COR#\d+$'
|
||||||
|
THEN SUBSTRING(TRIM(c.id_entidade) FROM 5)::integer
|
||||||
|
ELSE TRIM(c.id_entidade)::integer
|
||||||
|
END AS id_corrent,
|
||||||
c.id_chatwoot,
|
c.id_chatwoot,
|
||||||
TRIM(c.nome) AS nome,
|
TRIM(c.nome) AS nome,
|
||||||
TRIM(c.empresa) AS empresa,
|
TRIM(c.empresa) AS empresa,
|
||||||
@@ -530,9 +534,13 @@ SELECT
|
|||||||
TRIM(cr.razao::text) AS razao_cliente,
|
TRIM(cr.razao::text) AS razao_cliente,
|
||||||
cr.cod_vendedor
|
cr.cod_vendedor
|
||||||
FROM gestao.contato c
|
FROM gestao.contato c
|
||||||
LEFT JOIN sig.corrent cr
|
LEFT JOIN sig.corrent cr ON cr.id_corrent = CASE
|
||||||
ON cr.id_corrent = TRIM(c.id_entidade)::integer
|
WHEN TRIM(c.id_entidade) ~ '^COR#\d+$'
|
||||||
WHERE TRIM(c.id_entidade) ~ '^\d+$';
|
THEN SUBSTRING(TRIM(c.id_entidade) FROM 5)::integer
|
||||||
|
ELSE TRIM(c.id_entidade)::integer
|
||||||
|
END
|
||||||
|
WHERE TRIM(c.id_entidade) ~ '^\d+$'
|
||||||
|
OR TRIM(c.id_entidade) ~ '^COR#\d+$';
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- PARTE 2 — TABELAS DE ESCRITA DO SAR
|
-- PARTE 2 — TABELAS DE ESCRITA DO SAR
|
||||||
|
|||||||
@@ -489,8 +489,14 @@ CREATE INDEX IF NOT EXISTS idx_sar_ctnov_sync ON sar.contatos_novos(sincroniz
|
|||||||
CREATE OR REPLACE FUNCTION sar.fn_sync_contato_to_erp()
|
CREATE OR REPLACE FUNCTION sar.fn_sync_contato_to_erp()
|
||||||
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
|
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
|
||||||
DECLARE
|
DECLARE
|
||||||
v_id_contato INTEGER;
|
v_id_contato INTEGER;
|
||||||
|
v_id_empresa INTEGER;
|
||||||
BEGIN
|
BEGIN
|
||||||
|
-- Normaliza empresa fiscal (9001) → empresa gerencial (1), padrão do ERP
|
||||||
|
v_id_empresa := CASE WHEN NEW.id_empresa > 9000
|
||||||
|
THEN NEW.id_empresa - 9000
|
||||||
|
ELSE NEW.id_empresa END;
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO gestao.contato (
|
INSERT INTO gestao.contato (
|
||||||
id_empresa, id_entidade,
|
id_empresa, id_entidade,
|
||||||
@@ -499,8 +505,8 @@ BEGIN
|
|||||||
telefone, ramal, celular, whatsapp, email,
|
telefone, ramal, celular, whatsapp, email,
|
||||||
ativo, anotacoes
|
ativo, anotacoes
|
||||||
) VALUES (
|
) VALUES (
|
||||||
NEW.id_empresa,
|
v_id_empresa,
|
||||||
NEW.id_corrent::text,
|
'COR#' || NEW.id_corrent::text, -- formato nativo do ERP
|
||||||
COALESCE(LEFT(NEW.nome, 100), ''),
|
COALESCE(LEFT(NEW.nome, 100), ''),
|
||||||
LEFT(NEW.empresa, 100),
|
LEFT(NEW.empresa, 100),
|
||||||
LEFT(NEW.cargo, 100),
|
LEFT(NEW.cargo, 100),
|
||||||
|
|||||||
Reference in New Issue
Block a user