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:
@@ -9,8 +9,13 @@ import type {
|
||||
ClientSummary,
|
||||
Contato,
|
||||
ContatoResult,
|
||||
CtrSummary,
|
||||
CtrTitulo,
|
||||
CreateClientNovo,
|
||||
CreateContato,
|
||||
NotaFiscal,
|
||||
PedidoSummary,
|
||||
TopProduto,
|
||||
} from '@sar/api-interface';
|
||||
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> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
@@ -388,6 +548,7 @@ export class ClientsService {
|
||||
|
||||
return {
|
||||
idCliente: Number(r.id_cliente),
|
||||
|
||||
idEmpresa: Number(r.id_empresa),
|
||||
nome: r.nome,
|
||||
razao: r.razao,
|
||||
@@ -413,4 +574,106 @@ export class ClientsService {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user