Compare commits
27 Commits
a3c68f9f05
...
fix/rep-au
| Author | SHA1 | Date | |
|---|---|---|---|
| 996eff8e65 | |||
| f543b8ac7f | |||
| 518769218f | |||
| 2a33a5aa4b | |||
| 8838db16ae | |||
| a614147fc4 | |||
| 26857643fb | |||
| 51602dd47e | |||
| 2649bc9e94 | |||
| 264305386d | |||
| bf6e21ce47 | |||
| 32ff4b43fd | |||
| 795b805ae5 | |||
| a6cb1df2aa | |||
| 5bda00009f | |||
| 289c1a071e | |||
| 400fcb3360 | |||
| a2bab75bad | |||
| 0858e1e941 | |||
| fc6cc4c534 | |||
| 3413fa7003 | |||
| 34368e8006 | |||
| 236dd69eba | |||
| ec0b7c1611 | |||
| 35d0ba68d6 | |||
| 6f16dc8274 | |||
| 6fbf8bfb8e |
@@ -10,11 +10,6 @@ export default defineConfig({
|
|||||||
datasource: {
|
datasource: {
|
||||||
// Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio).
|
// Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio).
|
||||||
// Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }).
|
// Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }).
|
||||||
url:
|
url: process.env['DATABASE_URL'],
|
||||||
process.env['DATABASE_URL'] ??
|
|
||||||
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev',
|
|
||||||
},
|
|
||||||
migrations: {
|
|
||||||
seed: 'tsx prisma/seed.ts',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "FinancialStatus" AS ENUM ('regular', 'attention', 'blocked');
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Client" (
|
|
||||||
"id" UUID NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"tradeName" TEXT,
|
|
||||||
"taxId" TEXT NOT NULL,
|
|
||||||
"email" TEXT,
|
|
||||||
"phone" TEXT,
|
|
||||||
"address" JSONB,
|
|
||||||
"financialStatus" "FinancialStatus" NOT NULL DEFAULT 'regular',
|
|
||||||
"creditLimit" DECIMAL(15,2),
|
|
||||||
"repId" TEXT NOT NULL,
|
|
||||||
"lastOrderAt" TIMESTAMP(3),
|
|
||||||
"lastOrderValue" DECIMAL(15,2),
|
|
||||||
"openOrdersCount" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"erpCode" TEXT,
|
|
||||||
"syncedAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"deletedAt" TIMESTAMP(3),
|
|
||||||
|
|
||||||
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Client_taxId_key" ON "Client"("taxId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Client_repId_idx" ON "Client"("repId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Client_taxId_idx" ON "Client"("taxId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Client_name_idx" ON "Client"("name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Client_deletedAt_idx" ON "Client"("deletedAt");
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "OrderStatus" AS ENUM ('budget', 'pending_approval', 'approved', 'invoiced', 'cancelled');
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Order" (
|
|
||||||
"id" UUID NOT NULL,
|
|
||||||
"number" TEXT NOT NULL,
|
|
||||||
"clientId" UUID NOT NULL,
|
|
||||||
"repId" TEXT NOT NULL,
|
|
||||||
"status" "OrderStatus" NOT NULL DEFAULT 'budget',
|
|
||||||
"discountPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
|
|
||||||
"subtotal" DECIMAL(15,2) NOT NULL,
|
|
||||||
"total" DECIMAL(15,2) NOT NULL,
|
|
||||||
"notes" TEXT,
|
|
||||||
"approvedById" TEXT,
|
|
||||||
"approvedAt" TIMESTAMP(3),
|
|
||||||
"invoicedAt" TIMESTAMP(3),
|
|
||||||
"cancelledAt" TIMESTAMP(3),
|
|
||||||
"idempotencyKey" TEXT,
|
|
||||||
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"deletedAt" TIMESTAMP(3),
|
|
||||||
|
|
||||||
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "OrderItem" (
|
|
||||||
"id" UUID NOT NULL,
|
|
||||||
"orderId" UUID NOT NULL,
|
|
||||||
"productCode" TEXT NOT NULL,
|
|
||||||
"productName" TEXT NOT NULL,
|
|
||||||
"quantity" DECIMAL(10,3) NOT NULL,
|
|
||||||
"unitPrice" DECIMAL(15,2) NOT NULL,
|
|
||||||
"discountPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
|
|
||||||
"subtotal" DECIMAL(15,2) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "OrderStatusHistory" (
|
|
||||||
"id" UUID NOT NULL,
|
|
||||||
"orderId" UUID NOT NULL,
|
|
||||||
"fromStatus" "OrderStatus",
|
|
||||||
"toStatus" "OrderStatus" NOT NULL,
|
|
||||||
"changedById" TEXT NOT NULL,
|
|
||||||
"note" TEXT,
|
|
||||||
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "OrderStatusHistory_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Order_number_key" ON "Order"("number");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Order_idempotencyKey_key" ON "Order"("idempotencyKey");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Order_clientId_idx" ON "Order"("clientId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Order_repId_idx" ON "Order"("repId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Order_status_idx" ON "Order"("status");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Order_issuedAt_idx" ON "Order"("issuedAt");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Order_number_idx" ON "Order"("number");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Order_deletedAt_idx" ON "Order"("deletedAt");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "OrderStatusHistory_orderId_idx" ON "OrderStatusHistory"("orderId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "OrderStatusHistory_changedAt_idx" ON "OrderStatusHistory"("changedAt");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Order" ADD CONSTRAINT "Order_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "OrderStatusHistory" ADD CONSTRAINT "OrderStatusHistory_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "OrderItem" ADD COLUMN "productCategory" TEXT NOT NULL DEFAULT 'geral';
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Product" (
|
|
||||||
"id" UUID NOT NULL,
|
|
||||||
"code" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"description" TEXT,
|
|
||||||
"category" TEXT NOT NULL DEFAULT 'geral',
|
|
||||||
"unitPrice" DECIMAL(15,2) NOT NULL,
|
|
||||||
"stock" DECIMAL(10,3),
|
|
||||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"erpCode" TEXT,
|
|
||||||
"syncedAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"deletedAt" TIMESTAMP(3),
|
|
||||||
|
|
||||||
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "RepDiscountLimit" (
|
|
||||||
"repId" TEXT NOT NULL,
|
|
||||||
"category" TEXT NOT NULL,
|
|
||||||
"limit" DECIMAL(5,2) NOT NULL DEFAULT 5,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "RepDiscountLimit_pkey" PRIMARY KEY ("repId","category")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Product_code_idx" ON "Product"("code");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Product_name_idx" ON "Product"("name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Product_category_idx" ON "Product"("category");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Product_active_idx" ON "Product"("active");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Product_deletedAt_idx" ON "Product"("deletedAt");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "RepDiscountLimit_repId_idx" ON "RepDiscountLimit"("repId");
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "RepTarget" (
|
|
||||||
"repId" TEXT NOT NULL,
|
|
||||||
"year" INTEGER NOT NULL,
|
|
||||||
"month" INTEGER NOT NULL,
|
|
||||||
"targetAmount" DECIMAL(15,2) NOT NULL,
|
|
||||||
"commissionRate" DECIMAL(5,2) NOT NULL DEFAULT 3,
|
|
||||||
"flexRate" DECIMAL(5,2) NOT NULL DEFAULT 1,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "RepTarget_pkey" PRIMARY KEY ("repId","year","month")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "RepTarget_repId_idx" ON "RepTarget"("repId");
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "PushSubscription" (
|
|
||||||
"id" UUID NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"role" TEXT NOT NULL,
|
|
||||||
"endpoint" TEXT NOT NULL,
|
|
||||||
"p256dh" TEXT NOT NULL,
|
|
||||||
"auth" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "PushSubscription_role_idx" ON "PushSubscription"("role");
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable: sar.oportunidades (Funil de Vendas)
|
||||||
|
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
|
||||||
|
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
|
||||||
|
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.oportunidades (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
id_empresa INTEGER NOT NULL,
|
||||||
|
cod_vendedor INTEGER NOT NULL,
|
||||||
|
id_cliente INTEGER,
|
||||||
|
nome_prospect VARCHAR(200),
|
||||||
|
empresa_prospect VARCHAR(200),
|
||||||
|
titulo VARCHAR(200) NOT NULL,
|
||||||
|
etapa VARCHAR(20) NOT NULL DEFAULT 'lead',
|
||||||
|
valor_estimado NUMERIC(15,2),
|
||||||
|
observacoes TEXT,
|
||||||
|
id_pedido UUID REFERENCES sar.pedidos(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- CreateTable chamados
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.chamados (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
id_empresa INTEGER NOT NULL,
|
||||||
|
cod_vendedor INTEGER NOT NULL,
|
||||||
|
id_pedido UUID NOT NULL,
|
||||||
|
num_ped_sar INTEGER NOT NULL,
|
||||||
|
id_cliente INTEGER NOT NULL,
|
||||||
|
tipo VARCHAR(50) NOT NULL,
|
||||||
|
assunto VARCHAR(200) NOT NULL,
|
||||||
|
descricao TEXT NOT NULL,
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'aberto',
|
||||||
|
resolucao VARCHAR(50),
|
||||||
|
nota_resolucao TEXT,
|
||||||
|
itens_afetados JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_status ON sar.chamados(id_empresa, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chamados_pedido ON sar.chamados(id_pedido);
|
||||||
|
|
||||||
|
-- CreateTable chamado_mensagens
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.chamado_mensagens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
id_chamado INTEGER NOT NULL REFERENCES sar.chamados(id) ON DELETE CASCADE,
|
||||||
|
autor VARCHAR(10) NOT NULL,
|
||||||
|
texto TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagens(id_chamado);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Torna id_pedido e num_ped_sar opcionais (chamados podem referenciar pedidos ERP)
|
||||||
|
ALTER TABLE sar.chamados ALTER COLUMN id_pedido DROP NOT NULL;
|
||||||
|
ALTER TABLE sar.chamados ALTER COLUMN num_ped_sar DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Referência ao pedido ERP (numero inteiro do SIG)
|
||||||
|
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS num_ped_erp INTEGER;
|
||||||
|
|
||||||
|
-- Nome do cliente desnormalizado (evita join em toda consulta)
|
||||||
|
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS nome_cliente VARCHAR(200);
|
||||||
|
|
||||||
|
-- Remove index por id_pedido (agora nullable, menos útil como index único)
|
||||||
|
DROP INDEX IF EXISTS sar.idx_chamados_pedido;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Endereco de entrega alternativo do pedido (feature "novo pedido v2").
|
||||||
|
-- O campo existia no schema.prisma sem migration correspondente.
|
||||||
|
-- Idempotente: provision-client.ts roda sar-erp-schema.sql antes do migrate deploy.
|
||||||
|
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||||
|
ALTER TABLE sar.pedidos ADD COLUMN IF NOT EXISTS end_entrega TEXT;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable: sar.regras_desconto (Regras de Desconto - Politicas Comerciais)
|
||||||
|
-- Desconto liberado pelo gerente por grupo/subgrupo de produto e/ou
|
||||||
|
-- representante, com vigencia. Campos nulos = "todos".
|
||||||
|
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
|
||||||
|
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
|
||||||
|
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
id_empresa INTEGER NOT NULL,
|
||||||
|
descricao VARCHAR(200) NOT NULL,
|
||||||
|
cod_grupo INTEGER,
|
||||||
|
cod_subgrupo INTEGER,
|
||||||
|
cod_vendedor INTEGER,
|
||||||
|
desc_pct NUMERIC(5,2) NOT NULL,
|
||||||
|
data_inicio DATE NOT NULL,
|
||||||
|
data_fim DATE NOT NULL,
|
||||||
|
ativa BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
created_by VARCHAR(100) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa;
|
||||||
@@ -45,6 +45,7 @@ model Pedido {
|
|||||||
comissao Decimal @default(0) @db.Decimal(15, 2)
|
comissao Decimal @default(0) @db.Decimal(15, 2)
|
||||||
pedFlex Decimal @default(0) @db.Decimal(15, 2) @map("ped_flex")
|
pedFlex Decimal @default(0) @db.Decimal(15, 2) @map("ped_flex")
|
||||||
obs String?
|
obs String?
|
||||||
|
endEntrega String? @map("end_entrega")
|
||||||
aprovadoPor Int? @map("aprovado_por")
|
aprovadoPor Int? @map("aprovado_por")
|
||||||
aprovadoEm DateTime? @map("aprovado_em")
|
aprovadoEm DateTime? @map("aprovado_em")
|
||||||
motivoRecusa String? @map("motivo_recusa")
|
motivoRecusa String? @map("motivo_recusa")
|
||||||
@@ -147,6 +148,123 @@ model MetaRepresentante {
|
|||||||
@@map("meta_representante")
|
@@map("meta_representante")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Promocao (C8) ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Promoção comercial com validade, criada pelo Gerente. Pode ser por produto
|
||||||
|
// ou por grupo de produto. descPct em % (ex.: 5.00 = 5%).
|
||||||
|
|
||||||
|
model Promocao {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
descricao String
|
||||||
|
codProduto String? @map("cod_produto")
|
||||||
|
grpProd String? @map("grp_prod")
|
||||||
|
descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
|
||||||
|
dataInicio DateTime @db.Date @map("data_inicio")
|
||||||
|
dataFim DateTime @db.Date @map("data_fim")
|
||||||
|
ativa Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String @map("created_by")
|
||||||
|
|
||||||
|
@@index([idEmpresa])
|
||||||
|
@@index([idEmpresa, dataFim])
|
||||||
|
@@map("promocoes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
|
||||||
|
// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos".
|
||||||
|
|
||||||
|
model RegraDesconto {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
descricao String
|
||||||
|
codGrupo Int? @map("cod_grupo")
|
||||||
|
codSubgrupo Int? @map("cod_subgrupo")
|
||||||
|
codVendedor Int? @map("cod_vendedor")
|
||||||
|
descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
|
||||||
|
dataInicio DateTime @db.Date @map("data_inicio")
|
||||||
|
dataFim DateTime @db.Date @map("data_fim")
|
||||||
|
ativa Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String @map("created_by")
|
||||||
|
|
||||||
|
@@index([idEmpresa])
|
||||||
|
@@index([idEmpresa, dataFim])
|
||||||
|
@@map("regras_desconto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Oportunidade (Funil de Vendas) ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente)
|
||||||
|
// ou ser um prospect livre (nome_prospect / empresa_prospect).
|
||||||
|
// etapa: lead | proposta | negociacao | ganho | perdido
|
||||||
|
|
||||||
|
model Oportunidade {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
codVendedor Int @map("cod_vendedor")
|
||||||
|
idCliente Int? @map("id_cliente")
|
||||||
|
nomeProspect String? @map("nome_prospect") @db.VarChar(200)
|
||||||
|
empresaProspect String? @map("empresa_prospect") @db.VarChar(200)
|
||||||
|
titulo String @db.VarChar(200)
|
||||||
|
etapa String @default("lead") @db.VarChar(20)
|
||||||
|
valorEstimado Decimal? @db.Decimal(15, 2) @map("valor_estimado")
|
||||||
|
observacoes String?
|
||||||
|
idPedido String? @db.Uuid @map("id_pedido")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([idEmpresa, codVendedor])
|
||||||
|
@@index([etapa])
|
||||||
|
@@map("oportunidades")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chamados SAC ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Chamado aberto pelo representante vinculado a um pedido SAR.
|
||||||
|
// tipo: avaria | quantidade_divergente | produto_errado | prazo_entrega | outro
|
||||||
|
// status: aberto | em_analise | aguardando_rep | resolvido | cancelado
|
||||||
|
// resolucao: reposicao | desconto | cancelamento_item | sem_acao | outro
|
||||||
|
|
||||||
|
model Chamado {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idEmpresa Int @map("id_empresa")
|
||||||
|
codVendedor Int @map("cod_vendedor")
|
||||||
|
idPedido String? @db.Uuid @map("id_pedido")
|
||||||
|
numPedSar Int? @map("num_ped_sar")
|
||||||
|
numPedErp Int? @map("num_ped_erp")
|
||||||
|
idCliente Int @map("id_cliente")
|
||||||
|
nomeCliente String? @db.VarChar(200) @map("nome_cliente")
|
||||||
|
tipo String @db.VarChar(50)
|
||||||
|
assunto String @db.VarChar(200)
|
||||||
|
descricao String
|
||||||
|
status String @default("aberto") @db.VarChar(30)
|
||||||
|
resolucao String? @db.VarChar(50)
|
||||||
|
notaResolucao String? @map("nota_resolucao")
|
||||||
|
itensAfetados Json? @map("itens_afetados")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
mensagens ChamadoMensagem[]
|
||||||
|
|
||||||
|
@@index([idEmpresa, codVendedor])
|
||||||
|
@@index([idEmpresa, status])
|
||||||
|
@@map("chamados")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ChamadoMensagem {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
idChamado Int @map("id_chamado")
|
||||||
|
autor String @db.VarChar(10)
|
||||||
|
texto String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
chamado Chamado @relation(fields: [idChamado], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([idChamado])
|
||||||
|
@@map("chamado_mensagens")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
|
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
|
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,11 @@ 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 { PoliticasModule } from './politicas/politicas.module';
|
||||||
|
import { EquipeModule } from './equipe/equipe.module';
|
||||||
|
import { FunilModule } from './funil/funil.module';
|
||||||
|
import { SacModule } from './sac/sac.module';
|
||||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -29,6 +34,11 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
|||||||
CatalogModule,
|
CatalogModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
ReportsModule,
|
||||||
|
PoliticasModule,
|
||||||
|
EquipeModule,
|
||||||
|
FunilModule,
|
||||||
|
SacModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ export class DevAuthController {
|
|||||||
private readonly secret: Uint8Array;
|
private readonly secret: Uint8Array;
|
||||||
private readonly expiresIn: number;
|
private readonly expiresIn: number;
|
||||||
private readonly isProd: boolean;
|
private readonly isProd: boolean;
|
||||||
|
private readonly devEmpresaId: number;
|
||||||
|
|
||||||
constructor(config: ConfigService<Env, true>) {
|
constructor(config: ConfigService<Env, true>) {
|
||||||
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
||||||
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
|
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
|
||||||
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
||||||
|
this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('token')
|
@Post('token')
|
||||||
@@ -33,7 +35,7 @@ export class DevAuthController {
|
|||||||
if (this.isProd) throw new NotFoundException();
|
if (this.isProd) throw new NotFoundException();
|
||||||
|
|
||||||
const accessToken = await new SignJWT({
|
const accessToken = await new SignJWT({
|
||||||
id_empresa: dto.idEmpresa,
|
id_empresa: dto.idEmpresa ?? this.devEmpresaId,
|
||||||
role: dto.role,
|
role: dto.role,
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
|||||||
@@ -51,17 +51,21 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
|
|
||||||
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
||||||
|
|
||||||
// Em dev: força representante fixo (DEV_REP_CODE / DEV_EMPRESA_ID) ignorando o JWT.
|
// Em dev: usa sub/id_empresa do próprio JWT (emitido pelo /auth/dev/token,
|
||||||
// Em prod: usa os valores reais do JWT.
|
// que permite logar como Pavei/Sidnei/Lucas) e cai para DEV_REP_CODE /
|
||||||
const idEmpresa = this.isProd ? payload.id_empresa : this.devEmpresaId;
|
// DEV_EMPRESA_ID apenas se o token não trouxer os campos.
|
||||||
const userId = this.isProd ? payload.sub : this.devRepCode;
|
// Em prod: usa os valores reais do JWT do master-login.
|
||||||
|
const idEmpresa = payload.id_empresa ?? (this.isProd ? undefined : this.devEmpresaId);
|
||||||
|
const userId = payload.sub ?? (this.isProd ? undefined : this.devRepCode);
|
||||||
|
if (idEmpresa == null || userId == null) {
|
||||||
|
throw new UnauthorizedException('token sem sub/id_empresa');
|
||||||
|
}
|
||||||
this.cls.set('idEmpresa', idEmpresa);
|
this.cls.set('idEmpresa', idEmpresa);
|
||||||
this.cls.set('userId', userId);
|
this.cls.set('userId', userId);
|
||||||
this.cls.set('role', payload.role);
|
this.cls.set('role', payload.role);
|
||||||
|
|
||||||
const baseUrl =
|
const baseUrl = process.env['DATABASE_URL'];
|
||||||
process.env['DATABASE_URL'] ??
|
if (!baseUrl) throw new Error('DATABASE_URL não configurada');
|
||||||
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev';
|
|
||||||
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl));
|
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ProdutoListQuerySchema,
|
ProdutoListQuerySchema,
|
||||||
type EmpresaInfo,
|
type EmpresaInfo,
|
||||||
type FormaPagamento,
|
type FormaPagamento,
|
||||||
|
type Municipio,
|
||||||
type Pauta,
|
type Pauta,
|
||||||
type ProdutoDetail,
|
type ProdutoDetail,
|
||||||
type ProdutoListQuery,
|
type ProdutoListQuery,
|
||||||
@@ -19,6 +20,11 @@ class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
|
|||||||
export class CatalogController {
|
export class CatalogController {
|
||||||
constructor(private readonly catalog: CatalogService) {}
|
constructor(private readonly catalog: CatalogService) {}
|
||||||
|
|
||||||
|
@Get('municipios')
|
||||||
|
municipios(@Query('q') q?: string): Promise<Municipio[]> {
|
||||||
|
return this.catalog.municipios(q);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('pautas')
|
@Get('pautas')
|
||||||
pautas(): Promise<Pauta[]> {
|
pautas(): Promise<Pauta[]> {
|
||||||
return this.catalog.pautas();
|
return this.catalog.pautas();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ClsService } from 'nestjs-cls';
|
|||||||
import type {
|
import type {
|
||||||
EmpresaInfo,
|
EmpresaInfo,
|
||||||
FormaPagamento,
|
FormaPagamento,
|
||||||
|
Municipio,
|
||||||
Pauta,
|
Pauta,
|
||||||
ProdutoDetail,
|
ProdutoDetail,
|
||||||
ProdutoListQuery,
|
ProdutoListQuery,
|
||||||
@@ -84,15 +85,15 @@ export class CatalogService {
|
|||||||
TRIM(e.cnpj) AS cnpj,
|
TRIM(e.cnpj) AS cnpj,
|
||||||
TRIM(e.inscr_estadual) AS inscr_estadual,
|
TRIM(e.inscr_estadual) AS inscr_estadual,
|
||||||
TRIM(e.endereco) AS endereco,
|
TRIM(e.endereco) AS endereco,
|
||||||
NULLIF(e.numero, 0)::text AS numero,
|
e.num_endereco::text AS numero,
|
||||||
NULLIF(TRIM(e.complemento), '.') AS complemento,
|
e.complemento,
|
||||||
TRIM(e.bairro) AS bairro,
|
TRIM(e.bairro) AS bairro,
|
||||||
TRIM(m.nome) AS cidade,
|
TRIM(m.nome) AS cidade,
|
||||||
TRIM(e.estado::text) AS uf,
|
TRIM(e.uf) AS uf,
|
||||||
TRIM(e.cep::text) AS cep,
|
e.cep,
|
||||||
TRIM(e.telefone::text) AS telefone,
|
e.telefone,
|
||||||
TRIM(e.email) AS email
|
TRIM(e.email) AS email
|
||||||
FROM gestao.empresa e
|
FROM sar.vw_empresas e
|
||||||
LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio
|
LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio
|
||||||
WHERE e.id_empresa = ${idEmpresa}
|
WHERE e.id_empresa = ${idEmpresa}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -122,6 +123,35 @@ export class CatalogService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async municipios(q?: string): Promise<Municipio[]> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id_municipio: number;
|
||||||
|
nome: string;
|
||||||
|
uf: string;
|
||||||
|
codigo_ibge: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFilter = q ? `WHERE UPPER(nome) LIKE UPPER('%${escSql(q)}%')` : '';
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT id_municipio, TRIM(nome) AS nome, uf::text AS uf, codigo_ibge
|
||||||
|
FROM sar.vw_municipios
|
||||||
|
${searchFilter}
|
||||||
|
ORDER BY nome
|
||||||
|
LIMIT 100
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
idMunicipio: Number(r.id_municipio),
|
||||||
|
nome: r.nome,
|
||||||
|
uf: r.uf,
|
||||||
|
codigoIbge: r.codigo_ibge !== null ? Number(r.codigo_ibge) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async pautas(): Promise<Pauta[]> {
|
async pautas(): Promise<Pauta[]> {
|
||||||
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');
|
||||||
@@ -253,6 +283,7 @@ export class CatalogService {
|
|||||||
ativo: Number(p.ativo),
|
ativo: Number(p.ativo),
|
||||||
qtdEstoque: p.qtd_estoque,
|
qtdEstoque: p.qtd_estoque,
|
||||||
listaParauta: p.lista_pauta !== null ? Number(p.lista_pauta) : null,
|
listaParauta: p.lista_pauta !== null ? Number(p.lista_pauta) : null,
|
||||||
|
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +291,18 @@ export class CatalogService {
|
|||||||
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');
|
||||||
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
|
interface PautaPrecoRow {
|
||||||
|
id_pauta: number;
|
||||||
|
codigo: number;
|
||||||
|
descricao: string;
|
||||||
|
preco: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows, pautaRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<ProdutoRow[]>(`
|
||||||
SELECT
|
SELECT
|
||||||
p.id_erp,
|
p.id_erp,
|
||||||
p.codigo,
|
p.codigo,
|
||||||
@@ -289,7 +330,23 @@ export class CatalogService {
|
|||||||
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
|
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
|
||||||
WHERE p.id_erp = ${idErp} AND p.ativo = 1
|
WHERE p.id_erp = ${idErp} AND p.ativo = 1
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`);
|
`),
|
||||||
|
prisma.$queryRawUnsafe<PautaPrecoRow[]>(`
|
||||||
|
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao, pp.preco1::text AS preco
|
||||||
|
FROM vw_pauta_produtos pp
|
||||||
|
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
|
||||||
|
AND pa.id_empresa = ${idEmpresa}
|
||||||
|
AND pa.ativo = 1
|
||||||
|
JOIN vw_representantes r ON pa.codigo IN (
|
||||||
|
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||||
|
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||||
|
)
|
||||||
|
WHERE pp.id_produto = ${idErp}
|
||||||
|
AND r.codigo = ${codVendedor}
|
||||||
|
AND pp.preco1 > 0
|
||||||
|
ORDER BY pa.codigo
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
const p = rows[0];
|
const p = rows[0];
|
||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
@@ -306,6 +363,12 @@ export class CatalogService {
|
|||||||
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
|
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
|
||||||
precoComIpi: null,
|
precoComIpi: null,
|
||||||
precoPromocional: p.preco_promocional,
|
precoPromocional: p.preco_promocional,
|
||||||
|
pautaPrecos: pautaRows.map((r) => ({
|
||||||
|
idPauta: Number(r.id_pauta),
|
||||||
|
codigo: Number(r.codigo),
|
||||||
|
descricao: r.descricao,
|
||||||
|
preco: r.preco,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
ClientListQuerySchema,
|
ClientListQuerySchema,
|
||||||
|
CreateClientNovoSchema,
|
||||||
|
CreateContatoSchema,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
type ClientListQuery,
|
type ClientListQuery,
|
||||||
type ClientListResponse,
|
type ClientListResponse,
|
||||||
|
type ClientNovoResult,
|
||||||
|
type Contato,
|
||||||
|
type ContatoResult,
|
||||||
|
type CtrSummary,
|
||||||
|
type CreateClientNovo,
|
||||||
|
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';
|
||||||
|
|
||||||
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
|
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
|
||||||
|
class CreateClientNovoDto extends createZodDto(CreateClientNovoSchema) {}
|
||||||
|
class CreateContatoDto extends createZodDto(CreateContatoSchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'clients' })
|
@Controller({ path: 'clients' })
|
||||||
export class ClientsController {
|
export class ClientsController {
|
||||||
@@ -20,6 +34,67 @@ export class ClientsController {
|
|||||||
return this.clients.list(parsed);
|
return this.clients.list(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('novo')
|
||||||
|
createNovo(@Body() body: CreateClientNovoDto): Promise<ClientNovoResult> {
|
||||||
|
const parsed = CreateClientNovoSchema.parse(body) as CreateClientNovo;
|
||||||
|
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')
|
||||||
|
listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> {
|
||||||
|
return this.clients.listContacts(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/contacts')
|
||||||
|
createContato(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: CreateContatoDto,
|
||||||
|
): Promise<ContatoResult> {
|
||||||
|
const parsed = CreateContatoSchema.parse(body) as CreateContato;
|
||||||
|
return this.clients.createContato(id, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
|
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
|
||||||
return this.clients.findOne(id);
|
return this.clients.findOne(id);
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type {
|
import type {
|
||||||
|
ActivityStatus,
|
||||||
ClientDetail,
|
ClientDetail,
|
||||||
ClientListQuery,
|
ClientListQuery,
|
||||||
ClientListResponse,
|
ClientListResponse,
|
||||||
|
ClientNovoResult,
|
||||||
ClientSummary,
|
ClientSummary,
|
||||||
ActivityStatus,
|
Contato,
|
||||||
|
ContatoResult,
|
||||||
|
CtrSummary,
|
||||||
|
CtrTitulo,
|
||||||
|
CreateClientNovo,
|
||||||
|
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';
|
||||||
|
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||||
|
|
||||||
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
|
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
|
||||||
const ALERT_DAYS = 30;
|
const ALERT_DAYS = 30;
|
||||||
@@ -26,6 +37,12 @@ function escSql(s: string): string {
|
|||||||
return s.replace(/'/g, "''");
|
return s.replace(/'/g, "''");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Financeiro (CTR) e NF vivem na empresa MATRIZ. O ERP usa códigos > 9000 para
|
||||||
|
// origem de pedido (ex.: 9001 → matriz 1), espelhando catalog/dashboard/equipe.
|
||||||
|
function matrizEmpresa(idEmpresa: number): number {
|
||||||
|
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
}
|
||||||
|
|
||||||
// Row bruta do $queryRawUnsafe
|
// Row bruta do $queryRawUnsafe
|
||||||
interface ClientRow {
|
interface ClientRow {
|
||||||
id_cliente: number;
|
id_cliente: number;
|
||||||
@@ -98,6 +115,29 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
|
|||||||
export class ClientsService {
|
export class ClientsService {
|
||||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: rep só acessa clientes da própria carteira; supervisor acessa as
|
||||||
|
// carteiras da sua equipe (cod_supervisor); gerente/admin passam direto.
|
||||||
|
// NotFound (não Forbidden) para não revelar a existência de clientes de terceiros.
|
||||||
|
private async assertClienteDaCarteira(idCliente: number): Promise<void> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'rep' && role !== 'supervisor') return;
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||||
|
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||||
|
idCliente,
|
||||||
|
);
|
||||||
|
const dono = rows[0] ? Number(rows[0].cod_vendedor) : null;
|
||||||
|
const permitidos =
|
||||||
|
role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : [codVendedor];
|
||||||
|
if (dono == null || !permitidos.includes(dono)) {
|
||||||
|
throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async list(query: ClientListQuery): Promise<ClientListResponse> {
|
async list(query: ClientListQuery): Promise<ClientListResponse> {
|
||||||
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');
|
||||||
@@ -109,8 +149,13 @@ export class ClientsService {
|
|||||||
const { q, status, page, limit } = query;
|
const { q, status, page, limit } = query;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// Rep vê apenas sua carteira (cod_vendedor = seu código)
|
// Rep vê apenas sua carteira; supervisor vê as carteiras da equipe
|
||||||
const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : '';
|
const vendedorFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? `AND c.cod_vendedor = ${codVendedor}`
|
||||||
|
: role === 'supervisor'
|
||||||
|
? `AND c.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
|
||||||
|
: '';
|
||||||
const searchFilter = q
|
const searchFilter = q
|
||||||
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
|
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
|
||||||
: '';
|
: '';
|
||||||
@@ -187,7 +232,345 @@ export class ClientsService {
|
|||||||
return { data: mapped, total, page, limit };
|
return { data: mapped, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createNovo(dto: CreateClientNovo): Promise<ClientNovoResult> {
|
||||||
|
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 Row {
|
||||||
|
id: string;
|
||||||
|
id_corrent_erp: number | null;
|
||||||
|
sincronizado: boolean;
|
||||||
|
erro_sync: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const esc = (s: string) => s.replace(/'/g, "''");
|
||||||
|
const opt = (v: string | undefined) => (v != null ? `'${esc(v)}'` : 'NULL');
|
||||||
|
const optN = (v: number | undefined) => (v != null ? String(v) : 'NULL');
|
||||||
|
|
||||||
|
// INSERT retorna id — RETURNING lê valores pré-trigger, então
|
||||||
|
// fazemos SELECT separado para capturar o que o trigger gravou
|
||||||
|
const insertRows = await prisma.$queryRawUnsafe<[{ id: string }]>(`
|
||||||
|
INSERT INTO sar.clientes_novos (
|
||||||
|
id_empresa, cod_vendedor, pesso, consfinal,
|
||||||
|
nome, razao, cgcpf, suf_cgcpf, inscr, indicador_ie,
|
||||||
|
endereco, num_endereco, bairro, id_municipio, cep,
|
||||||
|
ddd, telefone, email, limite_credito, cod_formapag, cod_pauta
|
||||||
|
) VALUES (
|
||||||
|
${idEmpresa}, ${codVendedor}, ${dto.pesso}, ${dto.consfinal},
|
||||||
|
'${esc(dto.nome)}', '${esc(dto.razao)}',
|
||||||
|
${opt(dto.cgcpf)}, '${esc(dto.sufCgcpf ?? '')}',
|
||||||
|
'${esc(dto.inscr ?? '')}', ${optN(dto.indicadorIe)},
|
||||||
|
'${esc(dto.endereco ?? '')}', '${esc(dto.numEndereco ?? '')}',
|
||||||
|
'${esc(dto.bairro ?? '')}', ${dto.idMunicipio}, '${esc(dto.cep ?? '')}',
|
||||||
|
'${esc(dto.ddd ?? '')}', '${esc(dto.telefone ?? '')}',
|
||||||
|
'${esc(dto.email ?? '')}', ${dto.limiteCredito ?? 0},
|
||||||
|
${optN(dto.codFormapag)}, ${optN(dto.codPauta)}
|
||||||
|
)
|
||||||
|
RETURNING id::text
|
||||||
|
`);
|
||||||
|
|
||||||
|
const id = insertRows[0]?.id;
|
||||||
|
if (!id) throw new Error('Falha ao inserir cliente');
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT id::text, id_corrent_erp, sincronizado, erro_sync
|
||||||
|
FROM sar.clientes_novos WHERE id = '${id}'
|
||||||
|
`);
|
||||||
|
|
||||||
|
const r = rows[0];
|
||||||
|
if (!r) throw new Error('Falha ao recuperar cliente inserido');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
idCorrentErp: r.id_corrent_erp !== null ? Number(r.id_corrent_erp) : null,
|
||||||
|
sincronizado: r.sincronizado,
|
||||||
|
erroSync: r.erro_sync,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listContacts(idCorrent: number): Promise<Contato[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCorrent);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id_contato: number;
|
||||||
|
id_empresa: number;
|
||||||
|
id_corrent: number;
|
||||||
|
id_chatwoot: number | null;
|
||||||
|
nome: string;
|
||||||
|
empresa: string | null;
|
||||||
|
cargo: string | null;
|
||||||
|
departamento: string | null;
|
||||||
|
dt_aniversario: string | null;
|
||||||
|
telefone: string | null;
|
||||||
|
ramal: string | null;
|
||||||
|
celular: string | null;
|
||||||
|
whatsapp: string | null;
|
||||||
|
email: string | null;
|
||||||
|
ativo: number;
|
||||||
|
anotacoes: string | null;
|
||||||
|
nome_cliente: string | null;
|
||||||
|
razao_cliente: string | null;
|
||||||
|
cod_vendedor: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
SELECT id_contato, id_empresa, id_corrent, id_chatwoot,
|
||||||
|
nome, empresa, cargo, departamento,
|
||||||
|
dt_aniversario::text, telefone, ramal, celular, whatsapp, email,
|
||||||
|
ativo, anotacoes, nome_cliente, razao_cliente, cod_vendedor
|
||||||
|
FROM sar.vw_contatos
|
||||||
|
WHERE id_corrent = ${idCorrent} AND ativo = 1
|
||||||
|
ORDER BY nome
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
idContato: Number(r.id_contato),
|
||||||
|
idEmpresa: Number(r.id_empresa),
|
||||||
|
idCorrent: Number(r.id_corrent),
|
||||||
|
idChatwoot: r.id_chatwoot !== null ? Number(r.id_chatwoot) : null,
|
||||||
|
nome: r.nome,
|
||||||
|
empresa: r.empresa,
|
||||||
|
cargo: r.cargo,
|
||||||
|
departamento: r.departamento,
|
||||||
|
dtAniversario: r.dt_aniversario,
|
||||||
|
telefone: r.telefone,
|
||||||
|
ramal: r.ramal,
|
||||||
|
celular: r.celular,
|
||||||
|
whatsapp: r.whatsapp,
|
||||||
|
email: r.email,
|
||||||
|
ativo: Number(r.ativo),
|
||||||
|
anotacoes: r.anotacoes,
|
||||||
|
nomeCliente: r.nome_cliente,
|
||||||
|
razaoCliente: r.razao_cliente,
|
||||||
|
codVendedor: r.cod_vendedor !== null ? Number(r.cod_vendedor) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
|
||||||
|
await this.assertClienteDaCarteira(idCorrent);
|
||||||
|
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 Row {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
const esc = (s: string) => s.replace(/'/g, "''");
|
||||||
|
const opt = (v: string | undefined) => (v ? `'${esc(v)}'` : 'NULL');
|
||||||
|
|
||||||
|
const insertRows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||||
|
INSERT INTO sar.contatos_novos (
|
||||||
|
id_empresa, cod_vendedor, id_corrent,
|
||||||
|
nome, empresa, cargo, departamento,
|
||||||
|
dt_aniversario, telefone, ramal, celular, whatsapp, email, anotacoes
|
||||||
|
) VALUES (
|
||||||
|
${idEmpresa}, ${codVendedor}, ${idCorrent},
|
||||||
|
'${esc(dto.nome)}',
|
||||||
|
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
|
||||||
|
${dto.dtAniversario ? `'${esc(dto.dtAniversario)}'` : 'NULL'},
|
||||||
|
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
|
||||||
|
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
|
||||||
|
)
|
||||||
|
RETURNING id::text
|
||||||
|
`);
|
||||||
|
|
||||||
|
const id = insertRows[0]?.id;
|
||||||
|
if (!id) throw new Error('Falha ao inserir contato');
|
||||||
|
|
||||||
|
interface ResultRow {
|
||||||
|
id: string;
|
||||||
|
id_contato_erp: number | null;
|
||||||
|
sincronizado: boolean;
|
||||||
|
erro_sync: string | null;
|
||||||
|
}
|
||||||
|
const rows = await prisma.$queryRawUnsafe<ResultRow[]>(`
|
||||||
|
SELECT id::text, id_contato_erp, sincronizado, erro_sync
|
||||||
|
FROM sar.contatos_novos WHERE id = '${id}'
|
||||||
|
`);
|
||||||
|
|
||||||
|
const r = rows[0];
|
||||||
|
if (!r) throw new Error('Falha ao recuperar contato inserido');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
idContatoErp: r.id_contato_erp !== null ? Number(r.id_contato_erp) : null,
|
||||||
|
sincronizado: r.sincronizado,
|
||||||
|
erroSync: r.erro_sync,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
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[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
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 = matrizEmpresa(idEmpresa);
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id_produto: number;
|
||||||
|
codigo: string;
|
||||||
|
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.id_produto,
|
||||||
|
TRIM(p.codigo) AS codigo,
|
||||||
|
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.dt_pedido) AS ultima_compra,
|
||||||
|
(
|
||||||
|
SELECT i2.preco_unitario::numeric(15,2)::text
|
||||||
|
FROM sar.vw_peditens_erp i2
|
||||||
|
JOIN sar.vw_pedidos_erp p2 ON p2.id_pedido = i2.id_pedido
|
||||||
|
WHERE i2.id_produto = i.id_produto
|
||||||
|
AND p2.id_cliente = ped.id_cliente
|
||||||
|
AND p2.id_empresa = ${idEmpresa}
|
||||||
|
AND p2.situa NOT IN (5)
|
||||||
|
ORDER BY p2.dt_pedido DESC, p2.id_pedido DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS ultimo_preco
|
||||||
|
FROM sar.vw_pedidos_erp ped
|
||||||
|
JOIN sar.vw_peditens_erp i ON i.id_pedido = ped.id_pedido
|
||||||
|
JOIN sar.vw_produtos p ON p.id_erp = i.id_produto
|
||||||
|
AND p.id_empresa = ${idEmpresaMatriz}
|
||||||
|
WHERE ped.id_cliente = ${idCliente}
|
||||||
|
AND ped.id_empresa = ${idEmpresa}
|
||||||
|
AND ped.situa NOT IN (5)
|
||||||
|
GROUP BY i.id_produto, p.codigo, p.descricao, p.unidade, ped.id_cliente
|
||||||
|
ORDER BY SUM(i.qtd) DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
idProduto: Number(r.id_produto),
|
||||||
|
codProduto: r.codigo ?? '',
|
||||||
|
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> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
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 id_empresa = ${idEmpresaMatriz}
|
||||||
|
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> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
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');
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
@@ -211,6 +594,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,
|
||||||
@@ -236,4 +620,115 @@ export class ClientsService {
|
|||||||
dtAtual: r.dt_atual,
|
dtAtual: r.dt_atual,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
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 = $3
|
||||||
|
AND situacao = 'A'
|
||||||
|
AND saldo > 0
|
||||||
|
ORDER BY dt_vencimento ASC
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
idCliente,
|
||||||
|
limit,
|
||||||
|
idEmpresaMatriz,
|
||||||
|
);
|
||||||
|
|
||||||
|
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[]> {
|
||||||
|
await this.assertClienteDaCarteira(idCliente);
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
// NF fica na matriz; pedidos podem ter origem na matriz ou na empresa fiscal (matriz+9000)
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
const idEmpresaFiscal = idEmpresaMatriz + 9000;
|
||||||
|
|
||||||
|
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 sar.vw_nf nf
|
||||||
|
JOIN sar.vw_pedidos_erp p
|
||||||
|
ON p.numero = nf.num_entrega
|
||||||
|
AND p.tipo = 'E'
|
||||||
|
AND p.id_empresa IN ($3, $4)
|
||||||
|
WHERE p.id_cliente = $1
|
||||||
|
AND nf.id_empresa = $3
|
||||||
|
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,
|
||||||
|
idEmpresaMatriz,
|
||||||
|
idEmpresaFiscal,
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
|
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
@@ -11,6 +11,15 @@ export class DashboardController {
|
|||||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: dashboards gerenciais agregam dados da empresa toda (faturamento,
|
||||||
|
// ranking de todos os reps) — rep não pode acessá-los.
|
||||||
|
private assertGestor(): void {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role === 'rep') {
|
||||||
|
throw new ForbiddenException('Apenas supervisores e gerentes podem acessar este painel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get('rep')
|
@Get('rep')
|
||||||
repDashboard(): Promise<RepDashboard> {
|
repDashboard(): Promise<RepDashboard> {
|
||||||
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
|
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
|
||||||
@@ -18,6 +27,19 @@ export class DashboardController {
|
|||||||
|
|
||||||
@Get('supervisor')
|
@Get('supervisor')
|
||||||
supervisorDashboard(): Promise<SupervisorDashboard> {
|
supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||||
|
this.assertGestor();
|
||||||
return this.dashboard.supervisorDashboard();
|
return this.dashboard.supervisorDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('manager')
|
||||||
|
managerDashboard(
|
||||||
|
@Query('mes') mes?: string,
|
||||||
|
@Query('ano') ano?: string,
|
||||||
|
): Promise<ManagerDashboard> {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.dashboard.managerDashboard(
|
||||||
|
mes ? parseInt(mes, 10) : undefined,
|
||||||
|
ano ? parseInt(ano, 10) : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
|
import type {
|
||||||
|
RepDashboard,
|
||||||
|
SupervisorDashboard,
|
||||||
|
ManagerDashboard,
|
||||||
|
RankingRep,
|
||||||
|
PositivacaoRep,
|
||||||
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||||
|
|
||||||
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
|
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
|
||||||
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||||
@@ -35,16 +42,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 +97,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);
|
||||||
@@ -137,7 +125,24 @@ export class DashboardService {
|
|||||||
obs: string | null;
|
obs: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = await Promise.all([
|
interface RealizadoMesRow {
|
||||||
|
mes: string;
|
||||||
|
valor: string;
|
||||||
|
}
|
||||||
|
interface MetaMesRow {
|
||||||
|
mes: string;
|
||||||
|
tipo: string;
|
||||||
|
valor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
atingidoRows,
|
||||||
|
pedidosMesRows,
|
||||||
|
recentRows,
|
||||||
|
realizadoGrupoRows,
|
||||||
|
realizadoMesRows,
|
||||||
|
metaMesRows,
|
||||||
|
] = await Promise.all([
|
||||||
prisma.$queryRawUnsafe<TotalRow[]>(`
|
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||||
SELECT COALESCE(SUM(total), 0)::text AS total
|
SELECT COALESCE(SUM(total), 0)::text AS total
|
||||||
FROM vw_pedidos_erp
|
FROM vw_pedidos_erp
|
||||||
@@ -191,6 +196,30 @@ export class DashboardService {
|
|||||||
AND e.dt_pedido <= '${monthEndStr}'
|
AND e.dt_pedido <= '${monthEndStr}'
|
||||||
GROUP BY p.cod_grupo, grupo
|
GROUP BY p.cod_grupo, grupo
|
||||||
`),
|
`),
|
||||||
|
// Realizado por mês do ano corrente.
|
||||||
|
prisma.$queryRawUnsafe<RealizadoMesRow[]>(`
|
||||||
|
SELECT EXTRACT(MONTH FROM dt_pedido)::int::text AS mes,
|
||||||
|
COALESCE(SUM(total), 0)::text AS valor
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND cod_vendedor = ${codVendedor}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND EXTRACT(YEAR FROM dt_pedido) = ${year}
|
||||||
|
GROUP BY mes
|
||||||
|
ORDER BY mes
|
||||||
|
`),
|
||||||
|
// Meta (GL + GR) por mês do ano corrente.
|
||||||
|
prisma.$queryRawUnsafe<MetaMesRow[]>(`
|
||||||
|
SELECT mes::text, TRIM(tipo) AS tipo,
|
||||||
|
COALESCE(SUM(valor), 0)::text AS valor
|
||||||
|
FROM vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND cod_vendedor = ${codVendedor}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND TRIM(tipo) IN ('GL', 'GR')
|
||||||
|
GROUP BY mes, tipo
|
||||||
|
ORDER BY mes
|
||||||
|
`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const atingido = Number(atingidoRows[0]?.total ?? 0);
|
const atingido = Number(atingidoRows[0]?.total ?? 0);
|
||||||
@@ -198,32 +227,47 @@ 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 vw_pedidos_erp
|
||||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
WHERE situa NOT IN (5)
|
||||||
const inactiveClients = await prisma.$queryRawUnsafe<InativoRow[]>(`
|
AND id_empresa = ${idEmpresa}
|
||||||
|
GROUP BY id_cliente
|
||||||
|
),
|
||||||
|
pedido_mes AS (
|
||||||
|
SELECT DISTINCT id_cliente
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE situa NOT IN (5)
|
||||||
|
AND id_empresa = ${idEmpresa}
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
)
|
||||||
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,12 +301,48 @@ export class DashboardService {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => b.valorMeta - a.valorMeta);
|
.sort((a, b) => b.valorMeta - a.valorMeta);
|
||||||
|
|
||||||
|
// Deduplica por idCliente — vw_clientes pode retornar o mesmo cliente em
|
||||||
|
// mais de uma empresa; mantemos a primeira ocorrência (ORDER BY dt_max ASC NULLS FIRST).
|
||||||
|
const seenNP = new Set<number>();
|
||||||
|
const naoPositivados = naoPositivadosRows
|
||||||
|
.filter((c) => {
|
||||||
|
const id = Number(c.id_cliente);
|
||||||
|
if (seenNP.has(id)) return false;
|
||||||
|
seenNP.add(id);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Monta histório mensal: 12 meses do ano, cruzando realizado e meta.
|
||||||
|
const realizadoPorMes = new Map(realizadoMesRows.map((r) => [Number(r.mes), Number(r.valor)]));
|
||||||
|
const metaGlPorMes = new Map<number, number>();
|
||||||
|
const metaGrPorMes = new Map<number, number>();
|
||||||
|
for (const r of metaMesRows) {
|
||||||
|
const m = Number(r.mes);
|
||||||
|
if (r.tipo === 'GL') metaGlPorMes.set(m, (metaGlPorMes.get(m) ?? 0) + Number(r.valor));
|
||||||
|
else metaGrPorMes.set(m, (metaGrPorMes.get(m) ?? 0) + Number(r.valor));
|
||||||
|
}
|
||||||
|
const historicoMensal = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const m = i + 1;
|
||||||
|
const valor = realizadoPorMes.get(m) ?? 0;
|
||||||
|
const meta = metaGlPorMes.has(m) ? (metaGlPorMes.get(m) ?? 0) : (metaGrPorMes.get(m) ?? 0);
|
||||||
|
return { mes: m, valor, meta, atingiu: meta > 0 && valor >= meta };
|
||||||
|
});
|
||||||
|
|
||||||
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,
|
||||||
|
historicoMensal,
|
||||||
pedidosRecentes: recentRows.map((o) => ({
|
pedidosRecentes: recentRows.map((o) => ({
|
||||||
id: `erp-${o.id_pedido}`,
|
id: `erp-${o.id_pedido}`,
|
||||||
numPedSar: (o.num_ped_sar ?? '').trim(),
|
numPedSar: (o.num_ped_sar ?? '').trim(),
|
||||||
@@ -281,13 +361,255 @@ 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,
|
syncedAt: now.toISOString(),
|
||||||
diasSemCompra: c.dt_ultima_compra
|
};
|
||||||
? Math.floor((now.getTime() - c.dt_ultima_compra.getTime()) / 86_400_000)
|
}
|
||||||
: 999,
|
|
||||||
ultimaCompraValor: c.ultima_compra_valor,
|
async managerDashboard(mes?: number, ano?: number): Promise<ManagerDashboard> {
|
||||||
|
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 now = new Date();
|
||||||
|
|
||||||
|
function matrizEmpresa(id: number): number {
|
||||||
|
return id > 9000 ? id - 9000 : id;
|
||||||
|
}
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
const year = ano ?? now.getFullYear();
|
||||||
|
const month = mes ?? now.getMonth() + 1;
|
||||||
|
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
|
||||||
|
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
interface TotalRow {
|
||||||
|
count: string;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
interface RankingRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome: string | null;
|
||||||
|
pedidos: string;
|
||||||
|
clientes_atendidos: string;
|
||||||
|
faturamento: string;
|
||||||
|
meta_valor: string | null;
|
||||||
|
}
|
||||||
|
interface PromoRow {
|
||||||
|
count: string;
|
||||||
|
}
|
||||||
|
interface MetaTotalRow {
|
||||||
|
meta_total: string;
|
||||||
|
}
|
||||||
|
interface MetaGrupoRow {
|
||||||
|
cod_grupo: number | null;
|
||||||
|
desc_grupo: string | null;
|
||||||
|
valor: string;
|
||||||
|
qtdade: string;
|
||||||
|
peso: string;
|
||||||
|
vl_fator: string;
|
||||||
|
}
|
||||||
|
interface PositivacaoRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
|
total_clientes: string;
|
||||||
|
clientes_positivados: string;
|
||||||
|
}
|
||||||
|
interface PositivacaoDiaRow {
|
||||||
|
dia: string;
|
||||||
|
clientes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
statsRows,
|
||||||
|
rankingRows,
|
||||||
|
promoRows,
|
||||||
|
metaTotalRows,
|
||||||
|
positivacaoRows,
|
||||||
|
metaGrupoRows,
|
||||||
|
realizadoGrupoRows,
|
||||||
|
positivacaoDiariaRows,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||||
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<RankingRow[]>(`
|
||||||
|
SELECT p.cod_vendedor,
|
||||||
|
(SELECT r.nome FROM vw_representantes r
|
||||||
|
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome,
|
||||||
|
COUNT(*)::text AS pedidos,
|
||||||
|
COUNT(DISTINCT p.id_cliente)::text AS clientes_atendidos,
|
||||||
|
COALESCE(SUM(p.total), 0)::text AS faturamento,
|
||||||
|
(SELECT SUM(m.valor)::text FROM sar.vw_metas m
|
||||||
|
WHERE m.id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND m.cod_vendedor = p.cod_vendedor
|
||||||
|
AND m.ano = ${year}
|
||||||
|
AND m.mes = ${month}
|
||||||
|
AND TRIM(m.tipo) = 'GR') AS meta_valor
|
||||||
|
FROM vw_pedidos_erp p
|
||||||
|
WHERE p.id_empresa = ${idEmpresa}
|
||||||
|
AND p.situa NOT IN (1, 5)
|
||||||
|
AND p.dt_pedido >= '${monthStartStr}'
|
||||||
|
AND p.dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY p.cod_vendedor
|
||||||
|
ORDER BY SUM(p.total) DESC
|
||||||
|
LIMIT 10
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<PromoRow[]>(`
|
||||||
|
SELECT COUNT(*)::text AS count
|
||||||
|
FROM sar.promocoes
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND ativa = true
|
||||||
|
AND data_fim >= CURRENT_DATE
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<MetaTotalRow[]>(`
|
||||||
|
SELECT COALESCE(SUM(valor), 0)::text AS meta_total
|
||||||
|
FROM sar.vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND mes = ${month}
|
||||||
|
AND TRIM(tipo) = 'GR'
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<PositivacaoRow[]>(`
|
||||||
|
SELECT
|
||||||
|
c.cod_vendedor,
|
||||||
|
(SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_vendedor,
|
||||||
|
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
|
||||||
|
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS clientes_positivados
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DISTINCT id_cliente, cod_vendedor
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
|
||||||
|
WHERE c.cod_vendedor > 0
|
||||||
|
GROUP BY c.cod_vendedor
|
||||||
|
HAVING COUNT(DISTINCT c.id_cliente) > 0
|
||||||
|
ORDER BY COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END) DESC
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<MetaGrupoRow[]>(`
|
||||||
|
SELECT cod_grupo, TRIM(desc_grupo) AS desc_grupo,
|
||||||
|
SUM(valor)::text AS valor,
|
||||||
|
SUM(qtdade)::text AS qtdade,
|
||||||
|
SUM(peso)::text AS peso,
|
||||||
|
AVG(vl_fator)::text AS vl_fator
|
||||||
|
FROM sar.vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND mes = ${month}
|
||||||
|
AND TRIM(tipo) = 'GR'
|
||||||
|
GROUP BY cod_grupo, desc_grupo
|
||||||
|
ORDER BY SUM(valor) DESC
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<RealizadoGrupoRow[]>(`
|
||||||
|
SELECT p.cod_grupo,
|
||||||
|
COALESCE(NULLIF(TRIM(p.grupo), ''), p.cod_grupo::text) AS grupo,
|
||||||
|
COUNT(DISTINCT pi.id_pedido)::text AS pedidos,
|
||||||
|
COALESCE(SUM(pi.total), 0)::text AS valor,
|
||||||
|
COALESCE(SUM(pi.qtd), 0)::text AS qtd,
|
||||||
|
COALESCE(SUM(pi.qtd * COALESCE(p.peso_liquido, 0)), 0)::text AS peso
|
||||||
|
FROM vw_peditens_erp pi
|
||||||
|
JOIN vw_pedidos_erp e ON e.id_pedido = pi.id_pedido
|
||||||
|
JOIN vw_produtos p ON p.id_erp = pi.id_produto AND p.id_empresa = ${idEmpresaMatriz}
|
||||||
|
WHERE e.id_empresa = ${idEmpresa}
|
||||||
|
AND e.situa NOT IN (1, 5)
|
||||||
|
AND e.dt_pedido >= '${monthStartStr}'
|
||||||
|
AND e.dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY p.cod_grupo, grupo
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<PositivacaoDiaRow[]>(`
|
||||||
|
SELECT dt_pedido::date::text AS dia,
|
||||||
|
COUNT(DISTINCT id_cliente)::text AS clientes
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY dt_pedido::date
|
||||||
|
ORDER BY dia
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pedidosMes = Number(statsRows[0]?.count ?? 0);
|
||||||
|
const faturamentoMes = Number(statsRows[0]?.total ?? 0);
|
||||||
|
const ticketMedio = pedidosMes > 0 ? faturamentoMes / pedidosMes : 0;
|
||||||
|
|
||||||
|
const realGrupoPorCod = new Map<number, RealizadoGrupoRow>();
|
||||||
|
for (const r of realizadoGrupoRows) {
|
||||||
|
if (r.cod_grupo != null) realGrupoPorCod.set(Number(r.cod_grupo), r);
|
||||||
|
}
|
||||||
|
const metasPorGrupo = metaGrupoRows.map((m) => {
|
||||||
|
const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null;
|
||||||
|
const real = cod != null ? realGrupoPorCod.get(cod) : undefined;
|
||||||
|
const valorMeta = Number(m.valor);
|
||||||
|
const valorReal = real ? Number(real.valor) : 0;
|
||||||
|
const pesoMeta = Number(m.peso);
|
||||||
|
const pesoReal = real ? Number(real.peso) : 0;
|
||||||
|
return {
|
||||||
|
codigo: cod,
|
||||||
|
rotulo: m.desc_grupo || real?.grupo || `Grupo ${cod ?? '?'}`,
|
||||||
|
pedidos: real ? Number(real.pedidos) : 0,
|
||||||
|
valorMeta,
|
||||||
|
valorReal,
|
||||||
|
qtdMeta: Number(m.qtdade),
|
||||||
|
qtdReal: real ? Number(real.qtd) : 0,
|
||||||
|
pesoMeta,
|
||||||
|
pesoReal,
|
||||||
|
fatorMeta: Number(m.vl_fator),
|
||||||
|
fatorReal: pesoReal > 0 ? valorReal / pesoReal : 0,
|
||||||
|
pct: valorMeta > 0 ? Math.round((valorReal / valorMeta) * 100) : 0,
|
||||||
|
falta: Math.max(0, valorMeta - valorReal),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const rankingReps: RankingRep[] = rankingRows.map((r) => {
|
||||||
|
const fat = Number(r.faturamento);
|
||||||
|
const meta = r.meta_valor ? Number(r.meta_valor) : 0;
|
||||||
|
return {
|
||||||
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome ?? null,
|
||||||
|
pedidos: Number(r.pedidos),
|
||||||
|
clientesAtendidos: Number(r.clientes_atendidos),
|
||||||
|
faturamento: fat,
|
||||||
|
pctMeta: meta > 0 ? Math.round((fat / meta) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
|
||||||
|
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
|
||||||
|
|
||||||
|
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
|
||||||
|
const total = Number(r.total_clientes);
|
||||||
|
const positivados = Number(r.clientes_positivados);
|
||||||
|
return {
|
||||||
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome_vendedor ?? null,
|
||||||
|
totalClientes: total,
|
||||||
|
clientesPositivados: positivados,
|
||||||
|
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
faturamentoMes,
|
||||||
|
pedidosMes,
|
||||||
|
ticketMedio,
|
||||||
|
totalReps,
|
||||||
|
promocoesAtivas: Number(promoRows[0]?.count ?? 0),
|
||||||
|
metaTotal,
|
||||||
|
metasPorGrupo,
|
||||||
|
rankingReps,
|
||||||
|
positivacaoReps,
|
||||||
|
positivacaoDiaria: positivacaoDiariaRows.map((r) => ({
|
||||||
|
dia: r.dia,
|
||||||
|
clientes: Number(r.clientes),
|
||||||
})),
|
})),
|
||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
@@ -299,9 +621,21 @@ export class DashboardService {
|
|||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes);
|
||||||
|
// gerente/admin acessando este painel veem a empresa toda.
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
const team =
|
||||||
|
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null;
|
||||||
|
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
|
||||||
|
|
||||||
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
|
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
|
||||||
const approvalQueue = await prisma.pedido.findMany({
|
const approvalQueue = await prisma.pedido.findMany({
|
||||||
where: { idEmpresa, situa: SITUA_PENDENTE },
|
where: {
|
||||||
|
idEmpresa,
|
||||||
|
situa: SITUA_PENDENTE,
|
||||||
|
...(team ? { codVendedor: { in: team } } : {}),
|
||||||
|
},
|
||||||
orderBy: { dtPedido: 'asc' },
|
orderBy: { dtPedido: 'asc' },
|
||||||
take: 50,
|
take: 50,
|
||||||
});
|
});
|
||||||
@@ -325,12 +659,14 @@ export class DashboardService {
|
|||||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
||||||
FROM vw_pedidos_erp
|
FROM vw_pedidos_erp
|
||||||
WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}'
|
WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}'
|
||||||
|
${teamSqlFilter('cod_vendedor')}
|
||||||
`),
|
`),
|
||||||
prisma.$queryRawUnsafe<DayRow[]>(`
|
prisma.$queryRawUnsafe<DayRow[]>(`
|
||||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
||||||
FROM vw_pedidos_erp
|
FROM vw_pedidos_erp
|
||||||
WHERE id_empresa = ${idEmpresa} AND situa != 5
|
WHERE id_empresa = ${idEmpresa} AND situa != 5
|
||||||
AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}'
|
AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}'
|
||||||
|
${teamSqlFilter('cod_vendedor')}
|
||||||
`),
|
`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -350,6 +686,7 @@ export class DashboardService {
|
|||||||
AND p.id_empresa = ${idEmpresa}
|
AND p.id_empresa = ${idEmpresa}
|
||||||
AND p.situa != 5
|
AND p.situa != 5
|
||||||
WHERE c.ativo = 1
|
WHERE c.ativo = 1
|
||||||
|
${teamSqlFilter('c.cod_vendedor')}
|
||||||
GROUP BY c.id_cliente, c.cod_vendedor
|
GROUP BY c.id_cliente, c.cod_vendedor
|
||||||
HAVING MAX(p.dt_pedido) IS NULL
|
HAVING MAX(p.dt_pedido) IS NULL
|
||||||
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
||||||
|
|||||||
13
apps/api/src/app/equipe/equipe.controller.ts
Normal file
13
apps/api/src/app/equipe/equipe.controller.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import type { EquipeResponse } from '@sar/api-interface';
|
||||||
|
import { EquipeService } from './equipe.service';
|
||||||
|
|
||||||
|
@Controller({ path: 'equipe' })
|
||||||
|
export class EquipeController {
|
||||||
|
constructor(private readonly equipe: EquipeService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listEquipe(): Promise<EquipeResponse> {
|
||||||
|
return this.equipe.listEquipe();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/equipe/equipe.module.ts
Normal file
9
apps/api/src/app/equipe/equipe.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EquipeController } from './equipe.controller';
|
||||||
|
import { EquipeService } from './equipe.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [EquipeController],
|
||||||
|
providers: [EquipeService],
|
||||||
|
})
|
||||||
|
export class EquipeModule {}
|
||||||
113
apps/api/src/app/equipe/equipe.service.ts
Normal file
113
apps/api/src/app/equipe/equipe.service.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type { EquipeResponse } from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
|
function matrizEmpresa(idEmpresa: number): number {
|
||||||
|
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PedidosRepRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
pedidos: string;
|
||||||
|
faturamento: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetaRepRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
meta_valor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EquipeService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
async listEquipe(): Promise<EquipeResponse> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'manager' && role !== 'admin') {
|
||||||
|
throw new ForbiddenException('Apenas gerentes podem consultar a equipe');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth() + 1;
|
||||||
|
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
|
||||||
|
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const [repRows, pedidosRows, metaRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<RepRow[]>(
|
||||||
|
`SELECT codigo AS cod_vendedor, nome FROM vw_representantes ORDER BY nome`,
|
||||||
|
),
|
||||||
|
prisma.$queryRawUnsafe<PedidosRepRow[]>(`
|
||||||
|
SELECT cod_vendedor,
|
||||||
|
COUNT(*)::text AS pedidos,
|
||||||
|
COALESCE(SUM(total), 0)::text AS faturamento
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
GROUP BY cod_vendedor
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<MetaRepRow[]>(`
|
||||||
|
SELECT cod_vendedor,
|
||||||
|
SUM(valor)::text AS meta_valor
|
||||||
|
FROM sar.vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND mes = ${month}
|
||||||
|
AND TRIM(tipo) = 'GR'
|
||||||
|
GROUP BY cod_vendedor
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pedidosMap = new Map(
|
||||||
|
pedidosRows.map((r) => [
|
||||||
|
Number(r.cod_vendedor),
|
||||||
|
{ pedidos: Number(r.pedidos), faturamento: Number(r.faturamento) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const metaMap = new Map(metaRows.map((r) => [Number(r.cod_vendedor), Number(r.meta_valor)]));
|
||||||
|
|
||||||
|
// vw_representantes pode retornar duplicatas por cod_vendedor — deduplicar
|
||||||
|
const seenCods = new Set<number>();
|
||||||
|
const uniqueReps = repRows.filter((r) => {
|
||||||
|
const cod = Number(r.cod_vendedor);
|
||||||
|
if (seenCods.has(cod)) return false;
|
||||||
|
seenCods.add(cod);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const reps = uniqueReps.map((r) => {
|
||||||
|
const cod = Number(r.cod_vendedor);
|
||||||
|
const stats = pedidosMap.get(cod);
|
||||||
|
const meta = metaMap.get(cod) ?? 0;
|
||||||
|
const faturamento = stats?.faturamento ?? 0;
|
||||||
|
const pedidos = stats?.pedidos ?? 0;
|
||||||
|
return {
|
||||||
|
codVendedor: cod,
|
||||||
|
nomeVendedor: r.nome ?? null,
|
||||||
|
pedidosMes: pedidos,
|
||||||
|
faturamentoMes: faturamento,
|
||||||
|
ticketMedio: pedidos > 0 ? faturamento / pedidos : 0,
|
||||||
|
pctMeta: meta > 0 ? Math.round((faturamento / meta) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
reps,
|
||||||
|
totalReps: reps.length,
|
||||||
|
syncedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
48
apps/api/src/app/funil/funil.controller.ts
Normal file
48
apps/api/src/app/funil/funil.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
CreateOportunidadeSchema,
|
||||||
|
UpdateOportunidadeSchema,
|
||||||
|
type FunilResponse,
|
||||||
|
type Oportunidade,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { FunilService } from './funil.service';
|
||||||
|
|
||||||
|
class CreateDto extends createZodDto(CreateOportunidadeSchema) {}
|
||||||
|
class UpdateDto extends createZodDto(UpdateOportunidadeSchema) {}
|
||||||
|
|
||||||
|
@Controller('funil')
|
||||||
|
export class FunilController {
|
||||||
|
constructor(private readonly funil: FunilService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listar(): Promise<FunilResponse> {
|
||||||
|
return this.funil.listar();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
criar(@Body() dto: CreateDto): Promise<Oportunidade> {
|
||||||
|
return this.funil.criar(CreateOportunidadeSchema.parse(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
atualizar(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateDto): Promise<Oportunidade> {
|
||||||
|
return this.funil.atualizar(id, UpdateOportunidadeSchema.parse(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
deletar(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.funil.deletar(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/funil/funil.module.ts
Normal file
9
apps/api/src/app/funil/funil.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FunilController } from './funil.controller';
|
||||||
|
import { FunilService } from './funil.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [FunilController],
|
||||||
|
providers: [FunilService],
|
||||||
|
})
|
||||||
|
export class FunilModule {}
|
||||||
141
apps/api/src/app/funil/funil.service.ts
Normal file
141
apps/api/src/app/funil/funil.service.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type {
|
||||||
|
CreateOportunidadeDto,
|
||||||
|
UpdateOportunidadeDto,
|
||||||
|
FunilResponse,
|
||||||
|
EtapaFunil,
|
||||||
|
Oportunidade,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
|
||||||
|
type OportRow = {
|
||||||
|
id: number;
|
||||||
|
idCliente: number | null;
|
||||||
|
nomeProspect: string | null;
|
||||||
|
empresaProspect: string | null;
|
||||||
|
titulo: string;
|
||||||
|
etapa: string;
|
||||||
|
valorEstimado: unknown;
|
||||||
|
observacoes: string | null;
|
||||||
|
idPedido: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PrismaCtx = {
|
||||||
|
oportunidade: {
|
||||||
|
findMany: (args: object) => Promise<OportRow[]>;
|
||||||
|
create: (args: object) => Promise<OportRow>;
|
||||||
|
update: (args: object) => Promise<OportRow>;
|
||||||
|
delete: (args: object) => Promise<void>;
|
||||||
|
};
|
||||||
|
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FunilService {
|
||||||
|
constructor(private readonly cls: ClsService) {}
|
||||||
|
|
||||||
|
private ctx(): { prisma: PrismaCtx; idEmpresa: number; codVendedor: number } {
|
||||||
|
const prisma = this.cls.get('prisma') as PrismaCtx;
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa') as number;
|
||||||
|
const userId = this.cls.get('userId') as string | undefined;
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
return { prisma, idEmpresa, codVendedor };
|
||||||
|
}
|
||||||
|
|
||||||
|
private toOp(r: OportRow, nomeMap: Map<number, string>): Oportunidade {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
idCliente: r.idCliente,
|
||||||
|
nomeProspect: r.nomeProspect,
|
||||||
|
empresaProspect: r.empresaProspect,
|
||||||
|
titulo: r.titulo,
|
||||||
|
etapa: r.etapa as EtapaFunil,
|
||||||
|
valorEstimado: r.valorEstimado != null ? String(r.valorEstimado) : null,
|
||||||
|
observacoes: r.observacoes,
|
||||||
|
idPedido: r.idPedido,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
nomeCliente: r.idCliente ? (nomeMap.get(r.idCliente) ?? null) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listar(): Promise<FunilResponse> {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
|
||||||
|
const rows = await prisma.oportunidade.findMany({
|
||||||
|
where: { idEmpresa, codVendedor },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const idClientes = [
|
||||||
|
...new Set(rows.filter((r) => r.idCliente).map((r) => r.idCliente as number)),
|
||||||
|
];
|
||||||
|
const nomeMap = new Map<number, string>();
|
||||||
|
if (idClientes.length > 0) {
|
||||||
|
const ids = idClientes.join(',');
|
||||||
|
const clientes = await prisma.$queryRawUnsafe<ClienteRow>(
|
||||||
|
`SELECT id_cliente, TRIM(nome) AS nome, TRIM(razao) AS razao FROM vw_clientes WHERE id_cliente IN (${ids}) AND id_empresa = ${idEmpresa}`,
|
||||||
|
);
|
||||||
|
for (const c of clientes) {
|
||||||
|
nomeMap.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byEtapa = (etapa: EtapaFunil) =>
|
||||||
|
rows.filter((r) => r.etapa === etapa).map((r) => this.toOp(r, nomeMap));
|
||||||
|
|
||||||
|
return {
|
||||||
|
lead: byEtapa('lead'),
|
||||||
|
proposta: byEtapa('proposta'),
|
||||||
|
negociacao: byEtapa('negociacao'),
|
||||||
|
ganho: byEtapa('ganho'),
|
||||||
|
perdido: byEtapa('perdido'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async criar(dto: CreateOportunidadeDto): Promise<Oportunidade> {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const row = await prisma.oportunidade.create({
|
||||||
|
data: {
|
||||||
|
idEmpresa,
|
||||||
|
codVendedor,
|
||||||
|
idCliente: dto.idCliente ?? null,
|
||||||
|
nomeProspect: dto.nomeProspect ?? null,
|
||||||
|
empresaProspect: dto.empresaProspect ?? null,
|
||||||
|
titulo: dto.titulo,
|
||||||
|
etapa: dto.etapa ?? 'lead',
|
||||||
|
valorEstimado: dto.valorEstimado ?? null,
|
||||||
|
observacoes: dto.observacoes ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.toOp(row, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
async atualizar(id: number, dto: UpdateOportunidadeDto): Promise<Oportunidade> {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const row = await prisma.oportunidade.update({
|
||||||
|
where: { id, idEmpresa, codVendedor },
|
||||||
|
data: {
|
||||||
|
...(dto.idCliente !== undefined && { idCliente: dto.idCliente }),
|
||||||
|
...(dto.nomeProspect !== undefined && { nomeProspect: dto.nomeProspect }),
|
||||||
|
...(dto.empresaProspect !== undefined && { empresaProspect: dto.empresaProspect }),
|
||||||
|
...(dto.titulo !== undefined && { titulo: dto.titulo }),
|
||||||
|
...(dto.etapa !== undefined && { etapa: dto.etapa }),
|
||||||
|
...(dto.valorEstimado !== undefined && { valorEstimado: dto.valorEstimado }),
|
||||||
|
...(dto.observacoes !== undefined && { observacoes: dto.observacoes }),
|
||||||
|
...(dto.idPedido !== undefined && { idPedido: dto.idPedido }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.toOp(row, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletar(id: number): Promise<void> {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
await prisma.oportunidade.delete({ where: { id, idEmpresa, codVendedor } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { SubscribePayloadSchema, type SubscribePayload } from '@sar/api-interface';
|
import {
|
||||||
|
SubscribePayloadSchema,
|
||||||
|
UnsubscribePayloadSchema,
|
||||||
|
type SubscribePayload,
|
||||||
|
} from '@sar/api-interface';
|
||||||
import type { AuthenticatedRequest } from '../auth/jwt.types';
|
import type { AuthenticatedRequest } from '../auth/jwt.types';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
|
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
|
||||||
|
class UnsubscribeDto extends createZodDto(UnsubscribePayloadSchema) {}
|
||||||
|
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
export class NotificationsController {
|
export class NotificationsController {
|
||||||
@@ -18,8 +23,8 @@ export class NotificationsController {
|
|||||||
|
|
||||||
@Delete('unsubscribe')
|
@Delete('unsubscribe')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async unsubscribe(@Body() body: { endpoint: string }): Promise<void> {
|
async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
|
||||||
await this.svc.unsubscribe(body.endpoint);
|
await this.svc.unsubscribe(req.user.sub, body.endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('pending-count')
|
@Get('pending-count')
|
||||||
|
|||||||
@@ -40,11 +40,15 @@ export class NotificationsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async unsubscribe(endpoint: string): Promise<void> {
|
// Escopado ao dono: só remove subscription do próprio usuário — quem souber o
|
||||||
|
// endpoint de terceiro não consegue desregistrá-lo.
|
||||||
|
async unsubscribe(userId: string, endpoint: string): Promise<void> {
|
||||||
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');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : null;
|
||||||
|
|
||||||
await prisma.pushSubscription.deleteMany({ where: { endpoint } });
|
await prisma.pushSubscription.deleteMany({ where: { endpoint, idEmpresa, codVendedor } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -15,11 +16,14 @@ import { createZodDto } from 'nestjs-zod';
|
|||||||
import {
|
import {
|
||||||
AprovarPedidoSchema,
|
AprovarPedidoSchema,
|
||||||
CreatePedidoSchema,
|
CreatePedidoSchema,
|
||||||
|
PedidoErpConsultaQuerySchema,
|
||||||
PedidoListQuerySchema,
|
PedidoListQuerySchema,
|
||||||
RecusarPedidoSchema,
|
RecusarPedidoSchema,
|
||||||
type AprovarPedido,
|
type AprovarPedido,
|
||||||
type CreatePedido,
|
type CreatePedido,
|
||||||
type PedidoDetail,
|
type PedidoDetail,
|
||||||
|
type PedidoErpConsultaQuery,
|
||||||
|
type PedidoErpConsultaResponse,
|
||||||
type PedidoListQuery,
|
type PedidoListQuery,
|
||||||
type PedidoListResponse,
|
type PedidoListResponse,
|
||||||
type RecusarPedido,
|
type RecusarPedido,
|
||||||
@@ -31,6 +35,7 @@ class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
|||||||
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||||
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
||||||
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
||||||
|
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'orders' })
|
@Controller({ path: 'orders' })
|
||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
@@ -79,6 +84,22 @@ export class OrdersController {
|
|||||||
return this.orders.reject(id, parsed);
|
return this.orders.reject(id, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/cancel')
|
||||||
|
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||||
|
return this.orders.cancel(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('erp/:idPedido')
|
||||||
|
findOneErp(@Param('idPedido', ParseIntPipe) idPedido: number): Promise<PedidoDetail> {
|
||||||
|
return this.orders.findOneErp(idPedido);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('erp-consulta')
|
||||||
|
listErpConsulta(@Query() query: PedidoErpConsultaQueryDto): Promise<PedidoErpConsultaResponse> {
|
||||||
|
const parsed = PedidoErpConsultaQuerySchema.parse(query) as PedidoErpConsultaQuery;
|
||||||
|
return this.orders.listErpConsulta(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||||
return this.orders.findOne(id);
|
return this.orders.findOne(id);
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import type {
|
|||||||
AprovarPedido,
|
AprovarPedido,
|
||||||
CreatePedido,
|
CreatePedido,
|
||||||
PedidoDetail,
|
PedidoDetail,
|
||||||
|
PedidoErpConsultaItem,
|
||||||
|
PedidoErpConsultaQuery,
|
||||||
|
PedidoErpConsultaResponse,
|
||||||
PedidoListQuery,
|
PedidoListQuery,
|
||||||
PedidoListResponse,
|
PedidoListResponse,
|
||||||
PedidoSummary,
|
PedidoSummary,
|
||||||
RecusarPedido,
|
RecusarPedido,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
|
||||||
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
|
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
|
||||||
@@ -26,11 +30,6 @@ function sigToSar(sigSitua: number): number {
|
|||||||
return sigSitua === 5 ? 3 : sigSitua;
|
return sigSitua === 5 ? 3 : sigSitua;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mapeia situa SAR → situa SIG para usar nos filtros SQL contra vw_pedidos_erp.
|
|
||||||
function sarToSig(sarSitua: number): number {
|
|
||||||
return sarSitua === 3 ? 5 : sarSitua;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decimalToString(v: Prisma.Decimal | null | undefined): string {
|
function decimalToString(v: Prisma.Decimal | null | undefined): string {
|
||||||
return v ? v.toString() : '0';
|
return v ? v.toString() : '0';
|
||||||
}
|
}
|
||||||
@@ -53,45 +52,13 @@ export class OrdersService {
|
|||||||
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
|
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// Filtro de vendedor: rep vê apenas seus pedidos
|
// Rep vê só os próprios pedidos; supervisor vê os da sua equipe
|
||||||
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : '';
|
// (cod_supervisor em vw_representantes); gerente/admin veem tudo.
|
||||||
const clienteFilter = idCliente != null ? `AND e.id_cliente = ${idCliente}` : '';
|
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : [];
|
||||||
|
const where: Prisma.PedidoWhereInput = {
|
||||||
// Converte situa SAR → SIG para filtrar corretamente contra vw_pedidos_erp
|
|
||||||
const sigSitua = situa != null ? sarToSig(situa) : null;
|
|
||||||
const situaFilter = sigSitua != null ? `AND e.situa = ${sigSitua}` : '';
|
|
||||||
|
|
||||||
const pedSarFilter = numPedSar ? `AND TRIM(e.num_ped_sar) ILIKE '%${numPedSar}%'` : '';
|
|
||||||
const fromFilter = from ? `AND e.dt_pedido >= '${from}'` : '';
|
|
||||||
const toFilter = to ? `AND e.dt_pedido <= '${to}'` : '';
|
|
||||||
|
|
||||||
const filters = `
|
|
||||||
WHERE e.id_empresa = ${idEmpresa}
|
|
||||||
${vendedorFilter} ${clienteFilter} ${situaFilter}
|
|
||||||
${pedSarFilter} ${fromFilter} ${toFilter}
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface ErpRow {
|
|
||||||
id_pedido: number;
|
|
||||||
num_ped_sar: string;
|
|
||||||
numero: number;
|
|
||||||
id_cliente: number;
|
|
||||||
nome_cliente: string | null;
|
|
||||||
razao_cliente: string | null;
|
|
||||||
cod_vendedor: number;
|
|
||||||
nome_vendedor: string | null;
|
|
||||||
situa: number;
|
|
||||||
status_descr: string;
|
|
||||||
dt_pedido: Date;
|
|
||||||
total: string;
|
|
||||||
desconto_perc: string;
|
|
||||||
obs: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pedidos SAR-nativos (Orçamento/Transmitido) — ainda não estão no ERP.
|
|
||||||
const sarWhere: Prisma.PedidoWhereInput = {
|
|
||||||
idEmpresa,
|
idEmpresa,
|
||||||
...(role === 'rep' ? { codVendedor } : {}),
|
...(role === 'rep' ? { codVendedor } : {}),
|
||||||
|
...(role === 'supervisor' ? { codVendedor: { in: team } } : {}),
|
||||||
...(idCliente != null ? { idCliente } : {}),
|
...(idCliente != null ? { idCliente } : {}),
|
||||||
...(situa != null ? { situa } : {}),
|
...(situa != null ? { situa } : {}),
|
||||||
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
|
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
|
||||||
@@ -105,42 +72,13 @@ export class OrdersService {
|
|||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [sarPedidos, countRows] = await Promise.all([
|
const [pedidos, total] = await Promise.all([
|
||||||
prisma.pedido.findMany({ where: sarWhere, orderBy: { dtPedido: 'desc' } }),
|
prisma.pedido.findMany({ where, orderBy: { dtPedido: 'desc' }, skip: offset, take: limit }),
|
||||||
prisma.$queryRawUnsafe<[{ count: string }]>(`
|
prisma.pedido.count({ where }),
|
||||||
SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters}
|
|
||||||
`),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sarCount = sarPedidos.length;
|
const cliIds = [...new Set(pedidos.map((p) => p.idCliente))];
|
||||||
const erpTotal = Number(countRows[0]?.count ?? 0);
|
const repCods = [...new Set(pedidos.map((p) => p.codVendedor))];
|
||||||
const total = sarCount + erpTotal;
|
|
||||||
|
|
||||||
// Paginação combinada: SAR-nativos primeiro (ativos), depois histórico ERP.
|
|
||||||
const sarSlice = sarPedidos.slice(offset, offset + limit);
|
|
||||||
const erpNeeded = limit - sarSlice.length;
|
|
||||||
const erpOffset = Math.max(0, offset - sarCount);
|
|
||||||
|
|
||||||
const erpRows =
|
|
||||||
erpNeeded > 0
|
|
||||||
? await prisma.$queryRawUnsafe<ErpRow[]>(`
|
|
||||||
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
|
|
||||||
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
|
|
||||||
c.nome AS nome_cliente, c.razao AS razao_cliente,
|
|
||||||
(SELECT r.nome FROM vw_representantes r
|
|
||||||
WHERE r.codigo = e.cod_vendedor
|
|
||||||
LIMIT 1) AS nome_vendedor
|
|
||||||
FROM vw_pedidos_erp e
|
|
||||||
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
|
|
||||||
${filters}
|
|
||||||
ORDER BY e.dt_pedido DESC
|
|
||||||
LIMIT ${erpNeeded} OFFSET ${erpOffset}
|
|
||||||
`)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Resolve nomes (cliente/rep) dos pedidos SAR em lote — views globais.
|
|
||||||
const cliIds = [...new Set(sarSlice.map((p) => p.idCliente))];
|
|
||||||
const repCods = [...new Set(sarSlice.map((p) => p.codVendedor))];
|
|
||||||
const [cliNameRows, repNameRows] = await Promise.all([
|
const [cliNameRows, repNameRows] = await Promise.all([
|
||||||
cliIds.length
|
cliIds.length
|
||||||
? prisma.$queryRawUnsafe<
|
? prisma.$queryRawUnsafe<
|
||||||
@@ -158,7 +96,7 @@ export class OrdersService {
|
|||||||
const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c]));
|
const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c]));
|
||||||
const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
|
const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
|
||||||
|
|
||||||
const sarData: PedidoSummary[] = sarSlice.map((p) => ({
|
const data: PedidoSummary[] = pedidos.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
numPedSar: p.numPedSar,
|
numPedSar: p.numPedSar,
|
||||||
idCliente: p.idCliente,
|
idCliente: p.idCliente,
|
||||||
@@ -175,27 +113,193 @@ export class OrdersService {
|
|||||||
fonte: 'sar' as const,
|
fonte: 'sar' as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const erpData: PedidoSummary[] = erpRows.map((o) => ({
|
return { data, total, page, limit };
|
||||||
id: `erp-${o.id_pedido}`,
|
}
|
||||||
numPedSar: (o.num_ped_sar ?? '').trim(),
|
|
||||||
numero: Number(o.numero),
|
async listErpConsulta(query: PedidoErpConsultaQuery): Promise<PedidoErpConsultaResponse> {
|
||||||
idCliente: Number(o.id_cliente),
|
const prisma = this.cls.get('prisma');
|
||||||
nomeCliente: o.nome_cliente ?? null,
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
razaoCliente: o.razao_cliente ?? null,
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
codVendedor: Number(o.cod_vendedor),
|
const role = this.cls.get('role');
|
||||||
nomeVendedor: o.nome_vendedor ?? null,
|
const userId = this.cls.get('userId');
|
||||||
// Normaliza situa SIG → SAR para consistência com pedidos SAR
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
situa: sigToSar(Number(o.situa)),
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
statusDescr: o.status_descr,
|
|
||||||
dtPedido: new Date(o.dt_pedido).toISOString(),
|
const { search, from, to, page, limit } = query;
|
||||||
total: o.total ?? '0',
|
const offset = (page - 1) * limit;
|
||||||
descontoPerc: o.desconto_perc ?? '0',
|
|
||||||
obs: o.obs ?? null,
|
const dataHistorico = new Date();
|
||||||
createdAt: new Date(o.dt_pedido).toISOString(),
|
dataHistorico.setDate(dataHistorico.getDate() - 90);
|
||||||
fonte: 'erp' as const,
|
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Defesa em profundidade: o contrato já exige YYYY-MM-DD, mas como as datas
|
||||||
|
// são interpoladas no SQL abaixo, revalidamos antes de montar a query.
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if ((from && !DATE_RE.test(from)) || (to && !DATE_RE.test(to))) {
|
||||||
|
throw new BadRequestException('Datas devem estar no formato YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendedorFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? `AND a.cod_vendedor = ${codVendedor}`
|
||||||
|
: role === 'supervisor'
|
||||||
|
? `AND a.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
|
||||||
|
: '';
|
||||||
|
const fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : '';
|
||||||
|
const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : '';
|
||||||
|
const fromFilterP = from ? `AND a.dt_pedido >= '${from}'` : '';
|
||||||
|
const toFilterP = to ? `AND a.dt_pedido <= '${to}'` : '';
|
||||||
|
const safe = (s: string) => s.replace(/'/g, "''");
|
||||||
|
const searchFilter = search
|
||||||
|
? `AND (r.numero_pedido::text ILIKE '%${safe(search)}%' OR LOWER(cl.nome) LIKE LOWER('%${safe(search)}%') OR LOWER(cl.razao) LIKE LOWER('%${safe(search)}%'))`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
interface ErpConsultaRow {
|
||||||
|
numero_pedido: number;
|
||||||
|
data: Date;
|
||||||
|
dt_prevent: Date | null;
|
||||||
|
data_emissao: Date | null;
|
||||||
|
situa: number;
|
||||||
|
status_descr: string | null;
|
||||||
|
id_cliente: number;
|
||||||
|
nome_cliente: string | null;
|
||||||
|
razao_cliente: string | null;
|
||||||
|
cod_vendedor: number;
|
||||||
|
forma_pagamento: string | null;
|
||||||
|
total: string;
|
||||||
|
obs: string | null;
|
||||||
|
tipo: 'P' | 'E';
|
||||||
|
num_ped_sar: string | null;
|
||||||
|
numero_entrega: number | null;
|
||||||
|
numero_nf: number | null;
|
||||||
|
chave_acesso_nfe: string | null;
|
||||||
|
sit_ped: number | null;
|
||||||
|
id_pedido: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cte = `
|
||||||
|
WITH all_rows AS (
|
||||||
|
SELECT
|
||||||
|
b.numero AS numero_pedido,
|
||||||
|
COALESCE(b.dt_pedido, a.dt_pedido) AS data,
|
||||||
|
a.dt_prevent,
|
||||||
|
a.dt_emissao AS data_emissao,
|
||||||
|
a.situa,
|
||||||
|
NULL::text AS status_descr,
|
||||||
|
a.id_cliente,
|
||||||
|
a.cod_vendedor,
|
||||||
|
a.forma_pagamento,
|
||||||
|
a.total::text,
|
||||||
|
a.obs,
|
||||||
|
'E'::varchar(1) AS tipo,
|
||||||
|
TRIM(b.num_ped_sar) AS num_ped_sar,
|
||||||
|
a.numero AS numero_entrega,
|
||||||
|
d.numero AS numero_nf,
|
||||||
|
d.chave_acesso_nfe,
|
||||||
|
b.situa AS sit_ped,
|
||||||
|
a.id_pedido
|
||||||
|
FROM sar.vw_pedidos_erp a
|
||||||
|
LEFT JOIN sar.vw_pedidos_erp b
|
||||||
|
ON a.numer_ped_vinc = b.numero
|
||||||
|
AND a.id_empresa = b.id_empresa
|
||||||
|
AND b.tipo = 'P'
|
||||||
|
LEFT JOIN sar.vw_nf d
|
||||||
|
ON a.numero = d.num_entrega
|
||||||
|
AND d.id_empresa = ${idMatriz}
|
||||||
|
WHERE a.id_empresa = ${idEmpresa}
|
||||||
|
AND a.tipo = 'E'
|
||||||
|
${vendedorFilter}
|
||||||
|
AND b.id_pedido IS NOT NULL
|
||||||
|
AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${dataHistoricoStr}'
|
||||||
|
${fromFilterE} ${toFilterE}
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
a.numero AS numero_pedido,
|
||||||
|
a.dt_pedido AS data,
|
||||||
|
a.dt_prevent,
|
||||||
|
a.dt_emissao AS data_emissao,
|
||||||
|
a.situa,
|
||||||
|
c.descricao AS status_descr,
|
||||||
|
a.id_cliente,
|
||||||
|
a.cod_vendedor,
|
||||||
|
a.forma_pagamento,
|
||||||
|
a.total::text,
|
||||||
|
a.obs,
|
||||||
|
'P'::varchar(1) AS tipo,
|
||||||
|
TRIM(a.num_ped_sar) AS num_ped_sar,
|
||||||
|
NULL::integer AS numero_entrega,
|
||||||
|
NULL::integer AS numero_nf,
|
||||||
|
NULL::varchar AS chave_acesso_nfe,
|
||||||
|
NULL::integer AS sit_ped,
|
||||||
|
a.id_pedido
|
||||||
|
FROM sar.vw_pedidos_erp a
|
||||||
|
LEFT JOIN vw_sitpedido c
|
||||||
|
ON c.id_sitpedido = a.situa
|
||||||
|
WHERE a.id_empresa = ${idEmpresa}
|
||||||
|
AND a.tipo = 'P'
|
||||||
|
${vendedorFilter}
|
||||||
|
AND a.situa NOT IN (3, 4, 5, 6)
|
||||||
|
${fromFilterP} ${toFilterP}
|
||||||
|
),
|
||||||
|
named AS (
|
||||||
|
SELECT r.*, cl.nome AS nome_cliente, cl.razao AS razao_cliente
|
||||||
|
FROM all_rows r
|
||||||
|
LEFT JOIN vw_clientes cl ON cl.id_cliente = r.id_cliente
|
||||||
|
WHERE 1=1 ${searchFilter}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [countRows, dataRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<[{ count: string }]>(
|
||||||
|
`${cte} SELECT COUNT(*)::text AS count FROM named`,
|
||||||
|
),
|
||||||
|
prisma.$queryRawUnsafe<ErpConsultaRow[]>(
|
||||||
|
`${cte} SELECT * FROM named ORDER BY data DESC LIMIT ${limit} OFFSET ${offset}`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = Number(countRows[0]?.count ?? 0);
|
||||||
|
|
||||||
|
const data: PedidoErpConsultaItem[] = dataRows.map((r) => ({
|
||||||
|
numeroPedido: Number(r.numero_pedido),
|
||||||
|
data: r.data ? new Date(r.data).toISOString() : new Date().toISOString(),
|
||||||
|
dtPrevent: r.dt_prevent ? new Date(r.dt_prevent).toISOString() : null,
|
||||||
|
dataEmissao: r.data_emissao ? new Date(r.data_emissao).toISOString() : null,
|
||||||
|
situa: Number(r.situa),
|
||||||
|
statusDescr: r.status_descr ?? null,
|
||||||
|
idCliente: Number(r.id_cliente),
|
||||||
|
nomeCliente: r.nome_cliente ?? null,
|
||||||
|
razaoCliente: r.razao_cliente ?? null,
|
||||||
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
formaPagamento: r.forma_pagamento ?? null,
|
||||||
|
total: r.total ?? '0',
|
||||||
|
obs: r.obs ?? null,
|
||||||
|
tipo: r.tipo as 'P' | 'E',
|
||||||
|
numPedSar: r.num_ped_sar ?? null,
|
||||||
|
numeroEntrega: r.numero_entrega != null ? Number(r.numero_entrega) : null,
|
||||||
|
numeroNf: r.numero_nf != null ? Number(r.numero_nf) : null,
|
||||||
|
chaveAcessoNfe: r.chave_acesso_nfe ?? null,
|
||||||
|
sitPed: r.sit_ped != null ? Number(r.sit_ped) : null,
|
||||||
|
idPedido: r.id_pedido != null ? Number(r.id_pedido) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { data: [...sarData, ...erpData], total, page, limit };
|
return { data, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supervisor só age sobre pedidos da própria equipe; gerente/admin passam
|
||||||
|
// direto. NotFound (não Forbidden) para não revelar pedidos de outras equipes.
|
||||||
|
private async assertPedidoDaEquipe(codVendedorPedido: number, idPedido: string): Promise<void> {
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
if (role !== 'supervisor') return;
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const userId = this.cls.get('userId') ?? '0';
|
||||||
|
const team = await getTeamCodes(prisma, parseInt(userId, 10));
|
||||||
|
if (!team.includes(codVendedorPedido)) {
|
||||||
|
throw new NotFoundException(`Pedido ${idPedido} não encontrado`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string): Promise<PedidoDetail> {
|
async findOne(id: string): Promise<PedidoDetail> {
|
||||||
@@ -206,7 +310,12 @@ export class OrdersService {
|
|||||||
const userId = this.cls.get('userId');
|
const userId = this.cls.get('userId');
|
||||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
|
||||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
const repFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? { codVendedor }
|
||||||
|
: role === 'supervisor'
|
||||||
|
? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } }
|
||||||
|
: {};
|
||||||
|
|
||||||
const o = await prisma.pedido.findFirst({
|
const o = await prisma.pedido.findFirst({
|
||||||
where: { id, idEmpresa, ...repFilter },
|
where: { id, idEmpresa, ...repFilter },
|
||||||
@@ -228,13 +337,26 @@ export class OrdersService {
|
|||||||
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');
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const role = this.cls.get('role');
|
||||||
const userId = this.cls.get('userId') ?? '0';
|
const userId = this.cls.get('userId') ?? '0';
|
||||||
const codVendedor = parseInt(userId, 10);
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
// Idempotency-Key: retorna pedido existente sem re-processar
|
// PGD-AUTHZ: rep só lança pedido para cliente da própria carteira.
|
||||||
|
if (role === 'rep') {
|
||||||
|
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||||
|
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||||
|
dto.idCliente,
|
||||||
|
);
|
||||||
|
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
|
||||||
|
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency-Key: retorna pedido existente sem re-processar.
|
||||||
|
// Escopado a empresa + vendedor — chave de terceiro não vaza pedido alheio.
|
||||||
if (dto.idempotencyKey) {
|
if (dto.idempotencyKey) {
|
||||||
const existing = await prisma.pedido.findUnique({
|
const existing = await prisma.pedido.findFirst({
|
||||||
where: { idempotencyKey: dto.idempotencyKey },
|
where: { idempotencyKey: dto.idempotencyKey, idEmpresa, codVendedor },
|
||||||
include: {
|
include: {
|
||||||
itens: { orderBy: { ordem: 'asc' } },
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
historico: { orderBy: { changedAt: 'asc' } },
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
@@ -290,6 +412,7 @@ export class OrdersService {
|
|||||||
descontoPerc: dto.descontoPerc,
|
descontoPerc: dto.descontoPerc,
|
||||||
descontoValor: descontoValorGlobal,
|
descontoValor: descontoValorGlobal,
|
||||||
obs: dto.obs ?? null,
|
obs: dto.obs ?? null,
|
||||||
|
endEntrega: dto.endEntrega ?? null,
|
||||||
idempotencyKey: dto.idempotencyKey ?? null,
|
idempotencyKey: dto.idempotencyKey ?? null,
|
||||||
itens: { create: itemsData },
|
itens: { create: itemsData },
|
||||||
historico: {
|
historico: {
|
||||||
@@ -325,7 +448,10 @@ export class OrdersService {
|
|||||||
|
|
||||||
// Rep só transmite o próprio orçamento
|
// Rep só transmite o próprio orçamento
|
||||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
const pedido = await prisma.pedido.findFirst({
|
||||||
|
where: { id, idEmpresa, ...repFilter },
|
||||||
|
include: { itens: { orderBy: { ordem: 'asc' } } },
|
||||||
|
});
|
||||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
if (pedido.situa !== SITUA_ORCAMENTO)
|
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -343,6 +469,8 @@ export class OrdersService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.assertAlcadaItens(pedido, limitMap, limiteMax);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
||||||
await prisma.historicoPedido.create({
|
await prisma.historicoPedido.create({
|
||||||
@@ -366,6 +494,146 @@ export class OrdersService {
|
|||||||
return this.mapDetail(final);
|
return this.mapDetail(final);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
|
||||||
|
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
|
||||||
|
// global — deve caber no limite do grupo do produto (alcada_desconto por
|
||||||
|
// codGrupo; 0 = default), somado à maior folga entre promoção ativa
|
||||||
|
// (produto/grupo) e regra de desconto vigente (grupo/subgrupo/rep). Cobre o
|
||||||
|
// desconto lançado no item E desconto disfarçado de preço unitário reduzido.
|
||||||
|
// Preço de tabela: pauta do pedido > promocional > preço base; produto sem
|
||||||
|
// referência valida só pelo campo de desconto.
|
||||||
|
private async assertAlcadaItens(
|
||||||
|
pedido: {
|
||||||
|
codVendedor: number;
|
||||||
|
descontoPerc: Prisma.Decimal;
|
||||||
|
idPauta: number | null;
|
||||||
|
itens: {
|
||||||
|
ordem: number;
|
||||||
|
idProduto: number;
|
||||||
|
codProduto: string | null;
|
||||||
|
precoUnitario: Prisma.Decimal;
|
||||||
|
descontoPerc: Prisma.Decimal;
|
||||||
|
}[];
|
||||||
|
},
|
||||||
|
limitMap: Map<number, number>,
|
||||||
|
limiteDefault: number,
|
||||||
|
): Promise<void> {
|
||||||
|
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
if (pedido.itens.length === 0) return;
|
||||||
|
const prodIds = [...new Set(pedido.itens.map((i) => i.idProduto))];
|
||||||
|
|
||||||
|
interface ProdRow {
|
||||||
|
id_erp: number;
|
||||||
|
codigo: string | null;
|
||||||
|
cod_grupo: number | null;
|
||||||
|
cod_subgrupo: number | null;
|
||||||
|
vl_preco1: string | null;
|
||||||
|
preco_promocional: string | null;
|
||||||
|
preco_pauta: string | null;
|
||||||
|
}
|
||||||
|
// prodIds/idPauta vêm do banco (Int), não do payload — interpolação segura.
|
||||||
|
const prodRows = await prisma.$queryRawUnsafe<ProdRow[]>(`
|
||||||
|
SELECT p.id_erp, TRIM(p.codigo) AS codigo, p.cod_grupo, p.cod_subgrupo,
|
||||||
|
p.vl_preco1::text, p.preco_promocional::text,
|
||||||
|
${
|
||||||
|
pedido.idPauta != null
|
||||||
|
? `(SELECT pp.preco1::text FROM vw_pauta_produtos pp
|
||||||
|
WHERE pp.id_pauta = ${Number(pedido.idPauta)}
|
||||||
|
AND pp.id_produto = p.id_erp LIMIT 1)`
|
||||||
|
: 'NULL'
|
||||||
|
} AS preco_pauta
|
||||||
|
FROM vw_produtos p
|
||||||
|
WHERE p.id_empresa = ${idMatriz}
|
||||||
|
AND p.id_erp IN (${prodIds.join(',')})
|
||||||
|
`);
|
||||||
|
const prodMap = new Map(prodRows.map((p) => [Number(p.id_erp), p]));
|
||||||
|
|
||||||
|
const hoje = new Date();
|
||||||
|
const promos = await prisma.promocao.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regras de desconto vigentes que alcançam o rep dono do pedido
|
||||||
|
// (cod_vendedor nulo = todos os reps).
|
||||||
|
const regras = await prisma.regraDesconto.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
OR: [{ codVendedor: null }, { codVendedor: pedido.codVendedor }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const descGlobal = Number(pedido.descontoPerc);
|
||||||
|
const problemas: string[] = [];
|
||||||
|
|
||||||
|
for (const it of pedido.itens) {
|
||||||
|
const prod = prodMap.get(it.idProduto);
|
||||||
|
const codGrupo = prod?.cod_grupo != null ? Number(prod.cod_grupo) : null;
|
||||||
|
const codigo = prod?.codigo?.trim() || it.codProduto || String(it.idProduto);
|
||||||
|
|
||||||
|
const promoPct = promos
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
(p.codProduto && prod?.codigo && p.codProduto.trim() === prod.codigo.trim()) ||
|
||||||
|
(p.grpProd && codGrupo != null && p.grpProd.trim() === String(codGrupo)),
|
||||||
|
)
|
||||||
|
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
|
||||||
|
|
||||||
|
// Regra sem grupo/subgrupo vale para qualquer produto; com grupo e/ou
|
||||||
|
// subgrupo, o produto precisa casar com o que a regra especifica.
|
||||||
|
const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null;
|
||||||
|
const regraPct = regras
|
||||||
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
(r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) &&
|
||||||
|
(r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)),
|
||||||
|
)
|
||||||
|
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
|
||||||
|
|
||||||
|
const limiteItem =
|
||||||
|
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) +
|
||||||
|
Math.max(promoPct, regraPct);
|
||||||
|
|
||||||
|
// Preço de tabela: pauta do pedido > promocional (se menor) > preço base
|
||||||
|
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
|
||||||
|
const precoBase = precoPauta > 0 ? precoPauta : Number(prod?.vl_preco1 ?? 0);
|
||||||
|
const precoPromo = Number(prod?.preco_promocional ?? 0);
|
||||||
|
const tabela = precoPromo > 0 && precoPromo < precoBase ? precoPromo : precoBase;
|
||||||
|
|
||||||
|
const descItem = Number(it.descontoPerc);
|
||||||
|
const precoLiquido = Number(it.precoUnitario) * (1 - descItem / 100) * (1 - descGlobal / 100);
|
||||||
|
|
||||||
|
// Desconto efetivo: vs tabela quando há referência; senão só os percentuais
|
||||||
|
const descEfetivo =
|
||||||
|
tabela > 0
|
||||||
|
? (1 - precoLiquido / tabela) * 100
|
||||||
|
: (1 - (1 - descItem / 100) * (1 - descGlobal / 100)) * 100;
|
||||||
|
|
||||||
|
if (descEfetivo > limiteItem + 0.01) {
|
||||||
|
problemas.push(
|
||||||
|
`item ${it.ordem} (${codigo}) com desconto efetivo de ${descEfetivo.toFixed(1)}% — máximo permitido ${limiteItem}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problemas.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para transmitir.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
||||||
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');
|
||||||
@@ -379,6 +647,7 @@ export class OrdersService {
|
|||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||||
);
|
);
|
||||||
|
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
|
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
|
||||||
@@ -437,6 +706,7 @@ export class OrdersService {
|
|||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||||
);
|
);
|
||||||
|
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -473,6 +743,44 @@ export class OrdersService {
|
|||||||
return this.mapDetail(final);
|
return this.mapDetail(final);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancel(id: string): Promise<PedidoDetail> {
|
||||||
|
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') ?? '0';
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
|
// Rep só pode cancelar seu próprio orçamento (situa 0).
|
||||||
|
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, codVendedor } });
|
||||||
|
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
|
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||||
|
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_CANCELADO } });
|
||||||
|
await prisma.historicoPedido.create({
|
||||||
|
data: {
|
||||||
|
idPedido: id,
|
||||||
|
situaAnterior: SITUA_ORCAMENTO,
|
||||||
|
situaNova: SITUA_CANCELADO,
|
||||||
|
changedBy: codVendedor,
|
||||||
|
changedAt: now,
|
||||||
|
nota: 'Cancelado pelo representante',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const final = await prisma.pedido.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapDetail(final);
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve nome do cliente (nome + razão) e nome do representante a partir dos
|
// Resolve nome do cliente (nome + razão) e nome do representante a partir dos
|
||||||
// códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos.
|
// códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos.
|
||||||
private async lookupNames(
|
private async lookupNames(
|
||||||
@@ -523,6 +831,7 @@ export class OrdersService {
|
|||||||
aprovadoEm: Date | null;
|
aprovadoEm: Date | null;
|
||||||
motivoRecusa: string | null;
|
motivoRecusa: string | null;
|
||||||
obs: string | null;
|
obs: string | null;
|
||||||
|
endEntrega: string | null;
|
||||||
idempotencyKey: string | null;
|
idempotencyKey: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -560,6 +869,7 @@ export class OrdersService {
|
|||||||
total: decimalToString(o.total),
|
total: decimalToString(o.total),
|
||||||
descontoPerc: decimalToString(o.descontoPerc),
|
descontoPerc: decimalToString(o.descontoPerc),
|
||||||
obs: o.obs,
|
obs: o.obs,
|
||||||
|
endEntrega: o.endEntrega,
|
||||||
createdAt: o.createdAt.toISOString(),
|
createdAt: o.createdAt.toISOString(),
|
||||||
totalProdutos: decimalToString(o.totalProdutos),
|
totalProdutos: decimalToString(o.totalProdutos),
|
||||||
totalIpi: decimalToString(o.totalIpi),
|
totalIpi: decimalToString(o.totalIpi),
|
||||||
@@ -595,4 +905,136 @@ export class OrdersService {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findOneErp(idPedido: number): Promise<PedidoDetail> {
|
||||||
|
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 role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId') ?? '0';
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
|
interface ErpHeader {
|
||||||
|
id_pedido: number;
|
||||||
|
num_ped_sar: string;
|
||||||
|
numero: number;
|
||||||
|
id_cliente: number;
|
||||||
|
cod_vendedor: number;
|
||||||
|
situa: number;
|
||||||
|
status_descr: string;
|
||||||
|
dt_pedido: Date;
|
||||||
|
total_produtos: string;
|
||||||
|
total_ipi: string;
|
||||||
|
total_icmsst: string;
|
||||||
|
total: string;
|
||||||
|
desconto_perc: string;
|
||||||
|
desconto_valor: string;
|
||||||
|
acrescimo: string;
|
||||||
|
comissao: string;
|
||||||
|
ped_flex: string;
|
||||||
|
obs: string | null;
|
||||||
|
forma_pagamento: string | null;
|
||||||
|
nome_cliente: string | null;
|
||||||
|
razao_cliente: string | null;
|
||||||
|
nome_vendedor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErpItem {
|
||||||
|
ordem: number;
|
||||||
|
id_produto: number;
|
||||||
|
codigo: string | null;
|
||||||
|
descricao: string | null;
|
||||||
|
qtd: string;
|
||||||
|
preco_unitario: string;
|
||||||
|
desconto_perc: string;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendedorFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? `AND e.cod_vendedor = ${codVendedor}`
|
||||||
|
: role === 'supervisor'
|
||||||
|
? `AND e.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
|
||||||
|
: '';
|
||||||
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
const [headerRows, itemRows] = await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<ErpHeader[]>(`
|
||||||
|
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
|
||||||
|
e.situa, e.status_descr, e.dt_pedido,
|
||||||
|
e.total_produtos::text, e.total_ipi::text, e.total_icmsst::text,
|
||||||
|
e.total::text, e.desconto_perc::text, e.desconto_valor::text,
|
||||||
|
e.acrescimo::text, e.comissao::text, e.ped_flex::text, e.obs,
|
||||||
|
TRIM(e.forma_pagamento) AS forma_pagamento,
|
||||||
|
c.nome AS nome_cliente, c.razao AS razao_cliente,
|
||||||
|
(SELECT r.nome FROM vw_representantes r
|
||||||
|
WHERE r.codigo = e.cod_vendedor LIMIT 1) AS nome_vendedor
|
||||||
|
FROM vw_pedidos_erp e
|
||||||
|
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
|
||||||
|
WHERE e.id_empresa = ${idEmpresa}
|
||||||
|
AND e.id_pedido = ${idPedido}
|
||||||
|
${vendedorFilter}
|
||||||
|
LIMIT 1
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<ErpItem[]>(`
|
||||||
|
SELECT ei.ordem, ei.id_produto,
|
||||||
|
TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao,
|
||||||
|
ei.qtd::text, ei.preco_unitario::text,
|
||||||
|
ei.desconto_perc::text, ei.total::text
|
||||||
|
FROM vw_peditens_erp ei
|
||||||
|
LEFT JOIN vw_produtos p
|
||||||
|
ON p.id_erp = ei.id_produto
|
||||||
|
AND p.id_empresa = ${idMatriz}
|
||||||
|
WHERE ei.id_pedido = ${idPedido}
|
||||||
|
ORDER BY ei.ordem
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`);
|
||||||
|
const h = headerRows[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `erp-${h.id_pedido}`,
|
||||||
|
numPedSar: (h.num_ped_sar ?? '').trim(),
|
||||||
|
numero: Number(h.numero),
|
||||||
|
idCliente: Number(h.id_cliente),
|
||||||
|
nomeCliente: h.nome_cliente ?? null,
|
||||||
|
razaoCliente: h.razao_cliente ?? null,
|
||||||
|
codVendedor: Number(h.cod_vendedor),
|
||||||
|
nomeVendedor: h.nome_vendedor ?? null,
|
||||||
|
situa: sigToSar(Number(h.situa)),
|
||||||
|
statusDescr: h.status_descr,
|
||||||
|
dtPedido: new Date(h.dt_pedido).toISOString(),
|
||||||
|
total: h.total ?? '0',
|
||||||
|
descontoPerc: h.desconto_perc ?? '0',
|
||||||
|
obs: h.obs?.trim() || null,
|
||||||
|
createdAt: new Date(h.dt_pedido).toISOString(),
|
||||||
|
updatedAt: new Date(h.dt_pedido).toISOString(),
|
||||||
|
fonte: 'erp' as const,
|
||||||
|
formaPagamento: h.forma_pagamento || null,
|
||||||
|
totalProdutos: h.total_produtos ?? '0',
|
||||||
|
totalIpi: h.total_ipi ?? '0',
|
||||||
|
totalIcmsst: h.total_icmsst ?? '0',
|
||||||
|
descontoValor: h.desconto_valor ?? '0',
|
||||||
|
acrescimo: h.acrescimo ?? '0',
|
||||||
|
comissao: h.comissao ?? '0',
|
||||||
|
pedFlex: h.ped_flex ?? '0',
|
||||||
|
aprovadoPor: null,
|
||||||
|
aprovadoEm: null,
|
||||||
|
motivoRecusa: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
itens: itemRows.map((it) => ({
|
||||||
|
id: `${idPedido}-${it.ordem}`,
|
||||||
|
idProduto: Number(it.id_produto),
|
||||||
|
codProduto: it.codigo ?? null,
|
||||||
|
descProduto: it.descricao ?? null,
|
||||||
|
ordem: Number(it.ordem),
|
||||||
|
qtd: it.qtd,
|
||||||
|
precoUnitario: it.preco_unitario,
|
||||||
|
descontoPerc: it.desconto_perc,
|
||||||
|
total: it.total,
|
||||||
|
})),
|
||||||
|
historico: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
116
apps/api/src/app/politicas/politicas.controller.ts
Normal file
116
apps/api/src/app/politicas/politicas.controller.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
UpsertDescontoBodySchema,
|
||||||
|
CreatePromocaoBodySchema,
|
||||||
|
UpdatePromocaoBodySchema,
|
||||||
|
CreateRegraDescontoBodySchema,
|
||||||
|
UpdateRegraDescontoBodySchema,
|
||||||
|
type UpsertDescontoBody,
|
||||||
|
type CreatePromocaoBody,
|
||||||
|
type UpdatePromocaoBody,
|
||||||
|
type CreateRegraDescontoBody,
|
||||||
|
type UpdateRegraDescontoBody,
|
||||||
|
type AlcadaDescontosResponse,
|
||||||
|
type PromocoesResponse,
|
||||||
|
type PromocoesVigentesResponse,
|
||||||
|
type RegrasDescontoResponse,
|
||||||
|
type RegrasVigentesResponse,
|
||||||
|
type GruposProdutoResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { PoliticasService } from './politicas.service';
|
||||||
|
|
||||||
|
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
|
||||||
|
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
|
||||||
|
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
|
||||||
|
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
|
||||||
|
class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) {}
|
||||||
|
|
||||||
|
@Controller({ path: 'politicas' })
|
||||||
|
export class PoliticasController {
|
||||||
|
constructor(private readonly politicas: PoliticasService) {}
|
||||||
|
|
||||||
|
@Get('descontos')
|
||||||
|
listDescontos(): Promise<AlcadaDescontosResponse> {
|
||||||
|
return this.politicas.listDescontos();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('descontos')
|
||||||
|
@HttpCode(200)
|
||||||
|
upsertDesconto(@Body() body: UpsertDescontoDto): Promise<void> {
|
||||||
|
return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('promocoes')
|
||||||
|
listPromocoes(): Promise<PromocoesResponse> {
|
||||||
|
return this.politicas.listPromocoes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('promocoes/vigentes')
|
||||||
|
listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||||
|
return this.politicas.listPromocoesVigentes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('promocoes')
|
||||||
|
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
||||||
|
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('promocoes/:id')
|
||||||
|
updatePromocao(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: UpdatePromocaoDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.politicas.updatePromocao(id, body as unknown as UpdatePromocaoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('promocoes/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.politicas.deletePromocao(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('regras')
|
||||||
|
listRegras(): Promise<RegrasDescontoResponse> {
|
||||||
|
return this.politicas.listRegras();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('regras/vigentes')
|
||||||
|
listRegrasVigentes(): Promise<RegrasVigentesResponse> {
|
||||||
|
return this.politicas.listRegrasVigentes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('regras')
|
||||||
|
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ id: number }> {
|
||||||
|
return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('regras/:id')
|
||||||
|
updateRegra(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: UpdateRegraDescontoDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.politicas.updateRegra(id, body as unknown as UpdateRegraDescontoBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('regras/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
deleteRegra(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
|
return this.politicas.deleteRegra(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('grupos-produto')
|
||||||
|
listGruposProduto(): Promise<GruposProdutoResponse> {
|
||||||
|
return this.politicas.listGruposProduto();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/politicas/politicas.module.ts
Normal file
9
apps/api/src/app/politicas/politicas.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PoliticasController } from './politicas.controller';
|
||||||
|
import { PoliticasService } from './politicas.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PoliticasController],
|
||||||
|
providers: [PoliticasService],
|
||||||
|
})
|
||||||
|
export class PoliticasModule {}
|
||||||
382
apps/api/src/app/politicas/politicas.service.ts
Normal file
382
apps/api/src/app/politicas/politicas.service.ts
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type {
|
||||||
|
AlcadaDescontosResponse,
|
||||||
|
UpsertDescontoBody,
|
||||||
|
PromocoesResponse,
|
||||||
|
CreatePromocaoBody,
|
||||||
|
UpdatePromocaoBody,
|
||||||
|
RegrasDescontoResponse,
|
||||||
|
CreateRegraDescontoBody,
|
||||||
|
UpdateRegraDescontoBody,
|
||||||
|
RegrasVigentesResponse,
|
||||||
|
PromocoesVigentesResponse,
|
||||||
|
GruposProdutoResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PoliticasService {
|
||||||
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||||
|
|
||||||
|
private assertManager(): void {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'manager' && role !== 'admin') {
|
||||||
|
throw new ForbiddenException('Apenas gerentes podem gerenciar políticas comerciais');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDescontos(): Promise<AlcadaDescontosResponse> {
|
||||||
|
this.assertManager();
|
||||||
|
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 rows = await prisma.alcadaDesconto.findMany({
|
||||||
|
where: { idEmpresa },
|
||||||
|
orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Busca nomes dos representantes
|
||||||
|
const codigos = [...new Set(rows.map((r) => r.codVendedor))];
|
||||||
|
const repRows =
|
||||||
|
codigos.length > 0
|
||||||
|
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||||
|
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
descontos: rows.map((r) => ({
|
||||||
|
codVendedor: r.codVendedor,
|
||||||
|
codGrupo: r.codGrupo,
|
||||||
|
limitePerc: Number(r.limitePerc),
|
||||||
|
nomeVendedor: repNameMap.get(r.codVendedor) ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertDesconto(body: UpsertDescontoBody): Promise<void> {
|
||||||
|
this.assertManager();
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
await prisma.alcadaDesconto.upsert({
|
||||||
|
where: {
|
||||||
|
codVendedor_idEmpresa_codGrupo: {
|
||||||
|
codVendedor: body.codVendedor,
|
||||||
|
idEmpresa,
|
||||||
|
codGrupo: body.codGrupo ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
codVendedor: body.codVendedor,
|
||||||
|
idEmpresa,
|
||||||
|
codGrupo: body.codGrupo ?? 0,
|
||||||
|
limitePerc: body.limitePerc,
|
||||||
|
},
|
||||||
|
update: { limitePerc: body.limitePerc },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPromocoes(): Promise<PromocoesResponse> {
|
||||||
|
this.assertManager();
|
||||||
|
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 rows = await prisma.promocao.findMany({
|
||||||
|
where: { idEmpresa },
|
||||||
|
orderBy: [{ dataFim: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promocoes: rows.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
descricao: p.descricao,
|
||||||
|
codProduto: p.codProduto,
|
||||||
|
grpProd: p.grpProd,
|
||||||
|
descPct: Number(p.descPct),
|
||||||
|
dataInicio: p.dataInicio.toISOString().slice(0, 10),
|
||||||
|
dataFim: p.dataFim.toISOString().slice(0, 10),
|
||||||
|
ativa: p.ativa,
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
createdBy: p.createdBy,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPromocao(body: CreatePromocaoBody): Promise<{ id: number }> {
|
||||||
|
this.assertManager();
|
||||||
|
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') ?? 'system';
|
||||||
|
|
||||||
|
const p = await prisma.promocao.create({
|
||||||
|
data: {
|
||||||
|
idEmpresa,
|
||||||
|
descricao: body.descricao,
|
||||||
|
codProduto: body.codProduto ?? null,
|
||||||
|
grpProd: body.grpProd ?? null,
|
||||||
|
descPct: body.descPct,
|
||||||
|
dataInicio: new Date(body.dataInicio),
|
||||||
|
dataFim: new Date(body.dataFim),
|
||||||
|
createdBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: p.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
|
||||||
|
this.assertManager();
|
||||||
|
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 exists = await prisma.promocao.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!exists) throw new NotFoundException('Promoção não encontrada');
|
||||||
|
|
||||||
|
await prisma.promocao.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.descricao !== undefined && { descricao: body.descricao }),
|
||||||
|
...(body.codProduto !== undefined && { codProduto: body.codProduto }),
|
||||||
|
...(body.grpProd !== undefined && { grpProd: body.grpProd }),
|
||||||
|
...(body.descPct !== undefined && { descPct: body.descPct }),
|
||||||
|
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
|
||||||
|
...(body.dataFim !== undefined && { dataFim: new Date(body.dataFim) }),
|
||||||
|
...(body.ativa !== undefined && { ativa: body.ativa }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePromocao(id: number): Promise<void> {
|
||||||
|
this.assertManager();
|
||||||
|
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 exists = await prisma.promocao.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!exists) throw new NotFoundException('Promoção não encontrada');
|
||||||
|
|
||||||
|
await prisma.promocao.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Regras de Desconto ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Grupos/subgrupos distintos de vw_produtos (empresa matriz), para combos e
|
||||||
|
// resolução de nomes na listagem — UI sempre mostra o nome, nunca só o código.
|
||||||
|
private async loadGruposProduto(): Promise<
|
||||||
|
{
|
||||||
|
cod_grupo: number | null;
|
||||||
|
grupo: string | null;
|
||||||
|
cod_subgrupo: number | null;
|
||||||
|
subgrupo: string | null;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
return prisma.$queryRawUnsafe(
|
||||||
|
`SELECT DISTINCT cod_grupo, TRIM(grupo) AS grupo, cod_subgrupo, TRIM(subgrupo) AS subgrupo
|
||||||
|
FROM vw_produtos
|
||||||
|
WHERE id_empresa = ${idMatriz}
|
||||||
|
AND (cod_grupo IS NOT NULL OR cod_subgrupo IS NOT NULL)
|
||||||
|
ORDER BY grupo, subgrupo`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGruposProduto(): Promise<GruposProdutoResponse> {
|
||||||
|
this.assertManager();
|
||||||
|
const rows = await this.loadGruposProduto();
|
||||||
|
return {
|
||||||
|
grupos: rows.map((r) => ({
|
||||||
|
codGrupo: r.cod_grupo != null ? Number(r.cod_grupo) : null,
|
||||||
|
grupo: r.grupo,
|
||||||
|
codSubgrupo: r.cod_subgrupo != null ? Number(r.cod_subgrupo) : null,
|
||||||
|
subgrupo: r.subgrupo,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRegras(): Promise<RegrasDescontoResponse> {
|
||||||
|
this.assertManager();
|
||||||
|
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 rows = await prisma.regraDesconto.findMany({
|
||||||
|
where: { idEmpresa },
|
||||||
|
orderBy: [{ dataFim: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const codigos = [
|
||||||
|
...new Set(rows.map((r) => r.codVendedor).filter((c): c is number => c != null)),
|
||||||
|
];
|
||||||
|
const repRows =
|
||||||
|
codigos.length > 0
|
||||||
|
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||||
|
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
|
||||||
|
|
||||||
|
const grupos = rows.some((r) => r.codGrupo != null || r.codSubgrupo != null)
|
||||||
|
? await this.loadGruposProduto()
|
||||||
|
: [];
|
||||||
|
const grupoNameMap = new Map(
|
||||||
|
grupos.filter((g) => g.cod_grupo != null).map((g) => [Number(g.cod_grupo), g.grupo]),
|
||||||
|
);
|
||||||
|
const subgrupoNameMap = new Map(
|
||||||
|
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
regras: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
descricao: r.descricao,
|
||||||
|
codGrupo: r.codGrupo,
|
||||||
|
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||||
|
codSubgrupo: r.codSubgrupo,
|
||||||
|
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
|
||||||
|
codVendedor: r.codVendedor,
|
||||||
|
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
|
||||||
|
descPct: Number(r.descPct),
|
||||||
|
dataInicio: r.dataInicio.toISOString().slice(0, 10),
|
||||||
|
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||||
|
ativa: r.ativa,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
createdBy: r.createdBy,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> {
|
||||||
|
this.assertManager();
|
||||||
|
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') ?? 'system';
|
||||||
|
|
||||||
|
const r = await prisma.regraDesconto.create({
|
||||||
|
data: {
|
||||||
|
idEmpresa,
|
||||||
|
descricao: body.descricao,
|
||||||
|
codGrupo: body.codGrupo ?? null,
|
||||||
|
codSubgrupo: body.codSubgrupo ?? null,
|
||||||
|
codVendedor: body.codVendedor ?? null,
|
||||||
|
descPct: body.descPct,
|
||||||
|
dataInicio: new Date(body.dataInicio),
|
||||||
|
dataFim: new Date(body.dataFim),
|
||||||
|
createdBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: r.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> {
|
||||||
|
this.assertManager();
|
||||||
|
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 exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
|
||||||
|
|
||||||
|
await prisma.regraDesconto.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.descricao !== undefined && { descricao: body.descricao }),
|
||||||
|
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
|
||||||
|
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
|
||||||
|
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
|
||||||
|
...(body.descPct !== undefined && { descPct: body.descPct }),
|
||||||
|
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
|
||||||
|
...(body.dataFim !== undefined && { dataFim: new Date(body.dataFim) }),
|
||||||
|
...(body.ativa !== undefined && { ativa: body.ativa }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRegra(id: number): Promise<void> {
|
||||||
|
this.assertManager();
|
||||||
|
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 exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
|
||||||
|
|
||||||
|
await prisma.regraDesconto.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
|
||||||
|
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
|
||||||
|
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||||
|
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
|
||||||
|
const hoje = new Date();
|
||||||
|
const rows = await prisma.promocao.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
},
|
||||||
|
orderBy: [{ descPct: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promocoes: rows.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
descricao: p.descricao,
|
||||||
|
codProduto: p.codProduto,
|
||||||
|
grpProd: p.grpProd,
|
||||||
|
descPct: Number(p.descPct),
|
||||||
|
dataFim: p.dataFim.toISOString().slice(0, 10),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regras vigentes hoje para o usuário logado. Rep recebe só as regras que o
|
||||||
|
// alcançam (cod_vendedor nulo = todos, ou o próprio código); gerente vê todas.
|
||||||
|
async listRegrasVigentes(): Promise<RegrasVigentesResponse> {
|
||||||
|
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
const codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
|
||||||
|
|
||||||
|
const hoje = new Date();
|
||||||
|
const rows = await prisma.regraDesconto.findMany({
|
||||||
|
where: {
|
||||||
|
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||||
|
ativa: true,
|
||||||
|
dataInicio: { lte: hoje },
|
||||||
|
dataFim: { gte: hoje },
|
||||||
|
...(role === 'rep' ? { OR: [{ codVendedor: null }, { codVendedor }] } : {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ descPct: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
regras: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
descricao: r.descricao,
|
||||||
|
codGrupo: r.codGrupo,
|
||||||
|
codSubgrupo: r.codSubgrupo,
|
||||||
|
descPct: Number(r.descPct),
|
||||||
|
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {}
|
||||||
264
apps/api/src/app/reports/reports.service.ts
Normal file
264
apps/api/src/app/reports/reports.service.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
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';
|
||||||
|
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Rep vê a própria carteira; supervisor vê as carteiras da equipe
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null;
|
||||||
|
const vendCond = (col: string) =>
|
||||||
|
team ? `${col} IN (${team.join(',')})` : `${col} = ${codVendedor}`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
faturamento_total: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
|
||||||
|
WITH ultimos AS (
|
||||||
|
SELECT
|
||||||
|
id_cliente,
|
||||||
|
MAX(dt_pedido) AS ultimo_pedido,
|
||||||
|
COUNT(*) AS total_pedidos,
|
||||||
|
CASE WHEN COUNT(*) > 0
|
||||||
|
THEN SUM(total)::numeric / COUNT(*)
|
||||||
|
ELSE 0
|
||||||
|
END AS ticket_medio,
|
||||||
|
COALESCE(SUM(total), 0)::numeric AS faturamento_total
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND ${vendCond('cod_vendedor')}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
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,
|
||||||
|
COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
|
||||||
|
WHERE ${vendCond('c.cod_vendedor')}
|
||||||
|
AND c.ativo = 1
|
||||||
|
ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Deduplica por id_cliente — vw_clientes pode retornar o mesmo cliente em mais de uma empresa.
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const unique = rows.filter((r) => {
|
||||||
|
const id = n(r.id_cliente);
|
||||||
|
if (seen.has(id)) return false;
|
||||||
|
seen.add(id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const faturamentoRepTotal = unique.reduce((acc, r) => acc + n(r.faturamento_total), 0);
|
||||||
|
|
||||||
|
const data = unique.map((r) => {
|
||||||
|
const fat = n(r.faturamento_total);
|
||||||
|
return {
|
||||||
|
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),
|
||||||
|
faturamentoTotal: fat.toFixed(2),
|
||||||
|
participacaoPct:
|
||||||
|
faturamentoRepTotal > 0 ? Math.round((fat / faturamentoRepTotal) * 1000) / 10 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
faturamentoRepTotal: faturamentoRepTotal.toFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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}` : '';
|
||||||
|
|
||||||
|
// Rep vê os próprios pedidos; supervisor vê os da equipe
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null;
|
||||||
|
const vendCond = team
|
||||||
|
? `p.cod_vendedor IN (${team.join(',')})`
|
||||||
|
: `p.cod_vendedor = ${codVendedor}`;
|
||||||
|
|
||||||
|
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 ${vendCond}
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
91
apps/api/src/app/sac/sac.controller.ts
Normal file
91
apps/api/src/app/sac/sac.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
|
||||||
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
import { SacService } from './sac.service';
|
||||||
|
|
||||||
|
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
|
||||||
|
class AddMensagemDto extends createZodDto(AddMensagemSchema) {}
|
||||||
|
class ResolverDto extends createZodDto(ResolverChamadoSchema) {}
|
||||||
|
|
||||||
|
@Controller('chamados')
|
||||||
|
export class SacRepController {
|
||||||
|
constructor(private readonly sac: SacService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listar() {
|
||||||
|
return this.sac.listarRep();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detalhe(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.sac.detalhe(id, 'rep');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
criar(@Body() dto: CreateChamadoDto) {
|
||||||
|
return this.sac.criar(CreateChamadoSchema.parse(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/mensagens')
|
||||||
|
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
||||||
|
return this.sac.addMensagemRep(id, AddMensagemSchema.parse(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/cancelar')
|
||||||
|
cancelar(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.sac.cancelarRep(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('ger/chamados')
|
||||||
|
export class SacGerController {
|
||||||
|
constructor(
|
||||||
|
private readonly sac: SacService,
|
||||||
|
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// PGD-AUTHZ: visão da empresa toda (detalhe sem checagem de dono) — só gestores.
|
||||||
|
private assertGestor(): void {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role === 'rep') {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Apenas supervisores e gerentes podem acessar os chamados da empresa',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listar() {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.sac.listarEmpresa();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detalhe(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.sac.detalhe(id, 'empresa');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/mensagens')
|
||||||
|
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/resolver')
|
||||||
|
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/sac/sac.module.ts
Normal file
9
apps/api/src/app/sac/sac.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SacService } from './sac.service';
|
||||||
|
import { SacRepController, SacGerController } from './sac.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SacRepController, SacGerController],
|
||||||
|
providers: [SacService],
|
||||||
|
})
|
||||||
|
export class SacModule {}
|
||||||
195
apps/api/src/app/sac/sac.service.ts
Normal file
195
apps/api/src/app/sac/sac.service.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
import type { CreateChamadoDto, AddMensagemDto, ResolverChamadoDto } from '@sar/api-interface';
|
||||||
|
|
||||||
|
type PrismaCtx = {
|
||||||
|
chamado: {
|
||||||
|
findMany: (args: object) => Promise<unknown[]>;
|
||||||
|
findFirst: (args: object) => Promise<unknown | null>;
|
||||||
|
findUnique: (args: object) => Promise<unknown | null>;
|
||||||
|
create: (args: object) => Promise<unknown>;
|
||||||
|
update: (args: object) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
chamadoMensagem: { create: (args: object) => Promise<unknown> };
|
||||||
|
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SacService {
|
||||||
|
constructor(private readonly cls: ClsService) {}
|
||||||
|
|
||||||
|
private ctx(): { prisma: PrismaCtx; idEmpresa: number; codVendedor: number } {
|
||||||
|
const prisma = this.cls.get('prisma') as PrismaCtx;
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa') as number;
|
||||||
|
const userId = this.cls.get('userId') as string | undefined;
|
||||||
|
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||||
|
return { prisma, idEmpresa, codVendedor };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichNomes(
|
||||||
|
prisma: PrismaCtx,
|
||||||
|
idEmpresa: number,
|
||||||
|
rows: { idCliente: number }[],
|
||||||
|
): Promise<Map<number, string>> {
|
||||||
|
const ids = [...new Set(rows.map((r) => r.idCliente))];
|
||||||
|
if (!ids.length) return new Map();
|
||||||
|
const clientes = await prisma.$queryRawUnsafe<ClienteRow>(
|
||||||
|
`SELECT id_cliente, TRIM(nome) AS nome, TRIM(razao) AS razao FROM vw_clientes WHERE id_cliente IN (${ids.join(',')}) AND id_empresa = ${idEmpresa}`,
|
||||||
|
);
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
for (const c of clientes) {
|
||||||
|
map.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarRep() {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const all = (await prisma.chamado.findMany({
|
||||||
|
where: { idEmpresa, codVendedor },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})) as { idCliente: number; status: string; [k: string]: unknown }[];
|
||||||
|
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
|
||||||
|
const enrich = (row: (typeof all)[0]) => ({
|
||||||
|
...row,
|
||||||
|
nomeCliente:
|
||||||
|
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||||
|
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Códigos da equipe do supervisor (cod_supervisor em vw_representantes),
|
||||||
|
// incluindo o próprio. Retorna null para gerente/admin (sem filtro).
|
||||||
|
private async teamCodes(): Promise<number[] | null> {
|
||||||
|
const role = (this.cls.get('role') as string | undefined) ?? 'rep';
|
||||||
|
if (role !== 'supervisor') return null;
|
||||||
|
const { prisma, codVendedor } = this.ctx();
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ codigo: number }>(
|
||||||
|
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codVendedor}`,
|
||||||
|
);
|
||||||
|
return [...new Set([codVendedor, ...rows.map((r) => Number(r.codigo))])];
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarEmpresa() {
|
||||||
|
const { prisma, idEmpresa } = this.ctx();
|
||||||
|
// Supervisor vê só os chamados da sua equipe; gerente/admin veem a empresa toda
|
||||||
|
const team = await this.teamCodes();
|
||||||
|
const all = (await prisma.chamado.findMany({
|
||||||
|
where: { idEmpresa, ...(team ? { codVendedor: { in: team } } : {}) },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})) as { idCliente: number; status: string; [k: string]: unknown }[];
|
||||||
|
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
|
||||||
|
const enrich = (row: (typeof all)[0]) => ({
|
||||||
|
...row,
|
||||||
|
nomeCliente:
|
||||||
|
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||||
|
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detalhe(id: number, role: 'rep' | 'empresa') {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const chamado = (await prisma.chamado.findFirst({
|
||||||
|
where: { id, idEmpresa },
|
||||||
|
include: { mensagens: { orderBy: { createdAt: 'asc' } } },
|
||||||
|
})) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null;
|
||||||
|
if (!chamado) throw new NotFoundException('Chamado não encontrado');
|
||||||
|
if (role === 'rep' && chamado.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||||
|
if (role === 'empresa') {
|
||||||
|
const team = await this.teamCodes();
|
||||||
|
if (team && !team.includes(chamado.codVendedor)) throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
|
||||||
|
return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async criar(dto: CreateChamadoDto) {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const chamado = (await prisma.chamado.create({
|
||||||
|
data: {
|
||||||
|
idEmpresa,
|
||||||
|
codVendedor,
|
||||||
|
idPedido: dto.idPedido ?? null,
|
||||||
|
numPedSar: dto.numPedSar ?? null,
|
||||||
|
numPedErp: dto.numPedErp ?? null,
|
||||||
|
nomeCliente: dto.nomeCliente ?? null,
|
||||||
|
idCliente: dto.idCliente,
|
||||||
|
tipo: dto.tipo,
|
||||||
|
assunto: dto.assunto,
|
||||||
|
descricao: dto.descricao,
|
||||||
|
itensAfetados: dto.itensAfetados ?? [],
|
||||||
|
},
|
||||||
|
include: { mensagens: true },
|
||||||
|
})) as { idCliente: number; nomeCliente: string | null; [k: string]: unknown };
|
||||||
|
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
|
||||||
|
return { ...chamado, nomeCliente: chamado.nomeCliente ?? nomes.get(chamado.idCliente) ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMensagemRep(id: number, dto: AddMensagemDto) {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
|
||||||
|
codVendedor: number;
|
||||||
|
} | null;
|
||||||
|
if (!c) throw new NotFoundException();
|
||||||
|
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||||
|
await prisma.chamadoMensagem.create({
|
||||||
|
data: { idChamado: id, autor: 'rep', texto: dto.texto },
|
||||||
|
});
|
||||||
|
await prisma.chamado.update({ where: { id }, data: { status: 'em_analise' } });
|
||||||
|
return this.detalhe(id, 'rep');
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMensagemEmpresa(id: number, dto: AddMensagemDto) {
|
||||||
|
const { prisma, idEmpresa } = this.ctx();
|
||||||
|
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!c) throw new NotFoundException();
|
||||||
|
await prisma.chamadoMensagem.create({
|
||||||
|
data: { idChamado: id, autor: 'empresa', texto: dto.texto },
|
||||||
|
});
|
||||||
|
await prisma.chamado.update({ where: { id }, data: { status: 'aguardando_rep' } });
|
||||||
|
return this.detalhe(id, 'empresa');
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolver(id: number, dto: ResolverChamadoDto) {
|
||||||
|
const { prisma, idEmpresa } = this.ctx();
|
||||||
|
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
|
||||||
|
if (!c) throw new NotFoundException();
|
||||||
|
if (dto.mensagemFinal) {
|
||||||
|
await prisma.chamadoMensagem.create({
|
||||||
|
data: { idChamado: id, autor: 'empresa', texto: dto.mensagemFinal },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.chamado.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'resolvido',
|
||||||
|
resolucao: dto.resolucao,
|
||||||
|
notaResolucao: dto.notaResolucao ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.detalhe(id, 'empresa');
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelarRep(id: number) {
|
||||||
|
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||||
|
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
|
||||||
|
codVendedor: number;
|
||||||
|
status: string;
|
||||||
|
} | null;
|
||||||
|
if (!c) throw new NotFoundException();
|
||||||
|
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||||
|
if (['resolvido', 'cancelado'].includes(c.status))
|
||||||
|
throw new ForbiddenException('Chamado já encerrado');
|
||||||
|
await prisma.chamado.update({ where: { id }, data: { status: 'cancelado' } });
|
||||||
|
return this.detalhe(id, 'rep');
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/app/workspace/team-scope.util.ts
Normal file
14
apps/api/src/app/workspace/team-scope.util.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
// Códigos de vendedor da equipe de um supervisor (vw_representantes.cod_supervisor
|
||||||
|
// aponta para o código do supervisor), incluindo o próprio supervisor. Usado para
|
||||||
|
// escopar as telas do supervisor à sua equipe — gerente/admin veem a empresa toda.
|
||||||
|
// codSupervisor vem do JWT (parseInt) — interpolação segura.
|
||||||
|
export async function getTeamCodes(prisma: PrismaClient, codSupervisor: number): Promise<number[]> {
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ codigo: number }[]>(
|
||||||
|
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codSupervisor}`,
|
||||||
|
);
|
||||||
|
const codes = new Set(rows.map((r) => Number(r.codigo)));
|
||||||
|
codes.add(codSupervisor);
|
||||||
|
return [...codes];
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ const CACHEABLE_API = [
|
|||||||
|
|
||||||
// ── Fetch ──────────────────────────────────────────────────────────────────────
|
// ── Fetch ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
globalThis.addEventListener('fetch', (event) => {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
if (request.method !== 'GET') return;
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
@@ -102,10 +102,10 @@ function offlineResponse() {
|
|||||||
|
|
||||||
// ── Push (C6) ─────────────────────────────────────────────────────────────────
|
// ── Push (C6) ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
globalThis.addEventListener('push', (event) => {
|
||||||
const data = event.data?.json() ?? {};
|
const data = event.data?.json() ?? {};
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(data.title ?? 'SAR', {
|
globalThis.registration.showNotification(data.title ?? 'SAR', {
|
||||||
body: data.body ?? '',
|
body: data.body ?? '',
|
||||||
icon: '/sar-icon.png',
|
icon: '/sar-icon.png',
|
||||||
badge: '/sar-icon.png',
|
badge: '/sar-icon.png',
|
||||||
@@ -114,7 +114,7 @@ self.addEventListener('push', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
globalThis.addEventListener('notificationclick', (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const url = event.notification.data?.url;
|
const url = event.notification.data?.url;
|
||||||
if (url) {
|
if (url) {
|
||||||
|
|||||||
122
apps/web/src/cockpits/ger/EquipePage.tsx
Normal file
122
apps/web/src/cockpits/ger/EquipePage.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import type { RepStats } from '@sar/api-interface';
|
||||||
|
import { useEquipe } from '../../lib/queries/gerente';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
function fmt(v: number): string {
|
||||||
|
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<RepStats> = [
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
key: 'rep',
|
||||||
|
render: (_: unknown, row: RepStats) => (
|
||||||
|
<Flex vertical gap={0}>
|
||||||
|
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||||
|
{row.nomeVendedor && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
cód. {row.codVendedor}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'pedidosMes',
|
||||||
|
width: 90,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
sorter: (a, b) => a.pedidosMes - b.pedidosMes,
|
||||||
|
render: (v: number) => (v === 0 ? <Tag>0</Tag> : v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Faturamento',
|
||||||
|
dataIndex: 'faturamentoMes',
|
||||||
|
width: 160,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
sorter: (a, b) => a.faturamentoMes - b.faturamentoMes,
|
||||||
|
defaultSortOrder: 'descend',
|
||||||
|
render: (v: number) => fmt(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ticket Medio',
|
||||||
|
dataIndex: 'ticketMedio',
|
||||||
|
width: 140,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
sorter: (a, b) => a.ticketMedio - b.ticketMedio,
|
||||||
|
render: (v: number) => (v > 0 ? fmt(v) : <Text type="secondary">--</Text>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Meta',
|
||||||
|
dataIndex: 'pctMeta',
|
||||||
|
width: 170,
|
||||||
|
sorter: (a, b) => a.pctMeta - b.pctMeta,
|
||||||
|
render: (pct: number) =>
|
||||||
|
pct === 0 ? (
|
||||||
|
<Text type="secondary">sem meta</Text>
|
||||||
|
) : (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(pct, 100)}
|
||||||
|
size="small"
|
||||||
|
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||||
|
showInfo={false}
|
||||||
|
style={{ flex: 1, minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EquipePage() {
|
||||||
|
const { data, isLoading } = useEquipe();
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 1 }} />
|
||||||
|
<Skeleton active paragraph={{ rows: 6 }} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
|
Equipe
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
|
Performance dos representantes — {mes}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table<RepStats>
|
||||||
|
rowKey="codVendedor"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.reps}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum representante encontrado.' }}
|
||||||
|
scroll={{ x: 700 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
apps/web/src/cockpits/ger/GerChamadosPage.tsx
Normal file
346
apps/web/src/cockpits/ger/GerChamadosPage.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Tabs,
|
||||||
|
} from 'antd';
|
||||||
|
import { CustomerServiceOutlined } from '@ant-design/icons';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import {
|
||||||
|
STATUS_CHAMADO_LABEL,
|
||||||
|
TIPO_CHAMADO_LABEL,
|
||||||
|
RESOLUCAO_CHAMADO,
|
||||||
|
RESOLUCAO_CHAMADO_LABEL,
|
||||||
|
type Chamado,
|
||||||
|
type ChamadoMensagem,
|
||||||
|
type ItemAfetado,
|
||||||
|
type ResolverChamadoDto,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import {
|
||||||
|
useChamadosGer,
|
||||||
|
useChamadoGer,
|
||||||
|
useAddMensagemEmpresa,
|
||||||
|
useResolverChamado,
|
||||||
|
} from '../../lib/queries/sac';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
aberto: 'blue',
|
||||||
|
em_analise: 'orange',
|
||||||
|
aguardando_rep: 'purple',
|
||||||
|
resolvido: 'green',
|
||||||
|
cancelado: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResolverModal({ id, open, onClose }: { id: number; open: boolean; onClose: () => void }) {
|
||||||
|
const [form] = Form.useForm<ResolverChamadoDto>();
|
||||||
|
const resolver = useResolverChamado(id);
|
||||||
|
|
||||||
|
function handleOk() {
|
||||||
|
form.validateFields().then((values) => {
|
||||||
|
resolver.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.resetFields();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Resolver Chamado"
|
||||||
|
open={open}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={() => {
|
||||||
|
form.resetFields();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
okText="Confirmar Resolução"
|
||||||
|
cancelText="Voltar"
|
||||||
|
confirmLoading={resolver.isPending}
|
||||||
|
width={560}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="resolucao" label="Tipo de Resolução" rules={[{ required: true }]}>
|
||||||
|
<Select placeholder="Selecione...">
|
||||||
|
{RESOLUCAO_CHAMADO.map((r) => (
|
||||||
|
<Select.Option key={r} value={r}>
|
||||||
|
{RESOLUCAO_CHAMADO_LABEL[r]}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notaResolucao" label="Nota / Detalhes">
|
||||||
|
<TextArea rows={3} maxLength={1000} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="mensagemFinal" label="Mensagem final para o representante (opcional)">
|
||||||
|
<TextArea rows={2} maxLength={500} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChamadoDetalheModal({
|
||||||
|
id,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [texto, setTexto] = useState('');
|
||||||
|
const [resolverOpen, setResolverOpen] = useState(false);
|
||||||
|
const { data: chamado, isLoading } = useChamadoGer(id);
|
||||||
|
const addMsg = useAddMensagemEmpresa(id);
|
||||||
|
|
||||||
|
function enviar() {
|
||||||
|
if (!texto.trim()) return;
|
||||||
|
addMsg.mutate({ texto: texto.trim() }, { onSuccess: () => setTexto('') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fechado = chamado && ['resolvido', 'cancelado'].includes(chamado.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
title={chamado ? `Chamado #${chamado.id} — ${chamado.assunto}` : 'Chamado'}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={720}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{isLoading || !chamado ? (
|
||||||
|
<Spin />
|
||||||
|
) : (
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color={STATUS_COLOR[chamado.status]}>{STATUS_CHAMADO_LABEL[chamado.status]}</Tag>
|
||||||
|
<Tag>{TIPO_CHAMADO_LABEL[chamado.tipo] ?? chamado.tipo}</Tag>
|
||||||
|
<Text type="secondary">Rep: {chamado.codVendedor}</Text>
|
||||||
|
<Text type="secondary">Cliente: {chamado.nomeCliente ?? chamado.idCliente}</Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Text type="secondary">{chamado.descricao}</Text>
|
||||||
|
|
||||||
|
{chamado.notaResolucao && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f6ffed',
|
||||||
|
border: '1px solid #b7eb8f',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong>Resolução: </Text>
|
||||||
|
<Text>{chamado.notaResolucao}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chamado.itensAfetados && chamado.itensAfetados.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Text strong>Itens afetados: </Text>
|
||||||
|
{(chamado.itensAfetados as ItemAfetado[]).map((it, i) => (
|
||||||
|
<Tag key={`${it.codProduto}-${i}`}>
|
||||||
|
{it.codProduto} — {it.nomeProduto} (Qtd: {it.qtd})
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
maxHeight: 320,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(chamado.mensagens ?? []).length === 0 ? (
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ textAlign: 'center', display: 'block', padding: '16px 0' }}
|
||||||
|
>
|
||||||
|
Sem mensagens ainda.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
((chamado.mensagens as ChamadoMensagem[]) ?? []).map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: m.autor === 'empresa' ? 'flex-end' : 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '72%',
|
||||||
|
background: m.autor === 'empresa' ? '#1677ff' : '#f5f5f5',
|
||||||
|
color: m.autor === 'empresa' ? '#fff' : '#000',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 2 }}>
|
||||||
|
{m.autor === 'empresa' ? 'Empresa' : 'Representante'}
|
||||||
|
</div>
|
||||||
|
<div>{m.texto}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4, textAlign: 'right' }}>
|
||||||
|
{new Date(m.createdAt).toLocaleString('pt-BR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!fechado && (
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="Resposta">
|
||||||
|
<TextArea
|
||||||
|
rows={2}
|
||||||
|
value={texto}
|
||||||
|
onChange={(e) => setTexto(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={enviar}
|
||||||
|
loading={addMsg.isPending}
|
||||||
|
disabled={!texto.trim()}
|
||||||
|
>
|
||||||
|
Responder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
style={{ backgroundColor: '#52c41a', borderColor: '#52c41a' }}
|
||||||
|
onClick={() => setResolverOpen(true)}
|
||||||
|
>
|
||||||
|
Resolver
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ResolverModal id={id} open={resolverOpen} onClose={() => setResolverOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<Chamado> = [
|
||||||
|
{ title: '#', dataIndex: 'id', width: 60 },
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 140,
|
||||||
|
render: (s: string) => (
|
||||||
|
<Tag color={STATUS_COLOR[s] ?? 'default'}>{STATUS_CHAMADO_LABEL[s] ?? s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tipo',
|
||||||
|
dataIndex: 'tipo',
|
||||||
|
width: 160,
|
||||||
|
render: (t: string) => TIPO_CHAMADO_LABEL[t] ?? t,
|
||||||
|
},
|
||||||
|
{ title: 'Assunto', dataIndex: 'assunto', ellipsis: true },
|
||||||
|
{ title: 'Rep', dataIndex: 'codVendedor', width: 70 },
|
||||||
|
{ title: 'Cliente', dataIndex: 'nomeCliente', width: 160 },
|
||||||
|
{
|
||||||
|
title: 'Atualizado',
|
||||||
|
dataIndex: 'updatedAt',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GerChamadosPage() {
|
||||||
|
const { data, isLoading } = useChamadosGer();
|
||||||
|
const [detalheId, setDetalheId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
|
|
||||||
|
const abertos = data?.abertos ?? [];
|
||||||
|
const fechados = data?.fechados ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
|
||||||
|
<Space align="center" style={{ marginBottom: 24 }}>
|
||||||
|
<CustomerServiceOutlined style={{ fontSize: 24, color: '#1677ff' }} />
|
||||||
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
|
SAC — Chamados da Empresa
|
||||||
|
</Title>
|
||||||
|
{abertos.length > 0 && <Badge count={abertos.length} />}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'abertos',
|
||||||
|
label: `Abertos (${abertos.length})`,
|
||||||
|
children: (
|
||||||
|
<Table<Chamado>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={abertos}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||||
|
locale={{ emptyText: 'Nenhum chamado aberto.' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fechados',
|
||||||
|
label: `Fechados (${fechados.length})`,
|
||||||
|
children: (
|
||||||
|
<Table<Chamado>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={fechados}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
size="small"
|
||||||
|
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||||
|
locale={{ emptyText: 'Nenhum chamado fechado.' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{detalheId !== null && (
|
||||||
|
<ChamadoDetalheModal
|
||||||
|
id={detalheId}
|
||||||
|
open={detalheId !== null}
|
||||||
|
onClose={() => setDetalheId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
672
apps/web/src/cockpits/ger/GerPainel.tsx
Normal file
672
apps/web/src/cockpits/ger/GerPainel.tsx
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Flex,
|
||||||
|
InputNumber,
|
||||||
|
Progress,
|
||||||
|
Row,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faChartBar,
|
||||||
|
faTags,
|
||||||
|
faUsers,
|
||||||
|
faFileInvoiceDollar,
|
||||||
|
faAddressCard,
|
||||||
|
faBullseye,
|
||||||
|
faArrowTrendUp,
|
||||||
|
faCalendarDay,
|
||||||
|
faLayerGroup,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip as ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Chart } from 'react-chartjs-2';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
|
||||||
|
import { useManagerDashboard } from '../../lib/queries/gerente';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
// Meta diária de positivação é preferência local do gerente (não vem do ERP)
|
||||||
|
const META_POSITIVACAO_DIA_KEY = 'sar:ger:meta-positivacao-dia';
|
||||||
|
|
||||||
|
function fmt(v: number): string {
|
||||||
|
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function today(): string {
|
||||||
|
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
key: 'rep',
|
||||||
|
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Positivados / Carteira',
|
||||||
|
key: 'pos',
|
||||||
|
width: 160,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: unknown, row: PositivacaoRep) => (
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
{row.clientesPositivados} / {row.totalClientes}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Positivação',
|
||||||
|
dataIndex: 'pctPositivacao',
|
||||||
|
width: 170,
|
||||||
|
render: (pct: number) => (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(pct, 100)}
|
||||||
|
size="small"
|
||||||
|
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
|
||||||
|
showInfo={false}
|
||||||
|
style={{ flex: 1, minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const metasGrupoColumns: TableColumnsType<MetaItem> = [
|
||||||
|
{
|
||||||
|
title: 'Grupo',
|
||||||
|
dataIndex: 'rotulo',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Meta',
|
||||||
|
dataIndex: 'valorMeta',
|
||||||
|
width: 150,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => fmt(v),
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Realizado',
|
||||||
|
dataIndex: 'valorReal',
|
||||||
|
width: 150,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => fmt(v),
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Falta',
|
||||||
|
dataIndex: 'falta',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<Text
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{ fontSize: 'var(--text-sm)', color: v === 0 ? 'var(--green)' : undefined }}
|
||||||
|
>
|
||||||
|
{v === 0 ? '—' : fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Meta',
|
||||||
|
dataIndex: 'pct',
|
||||||
|
width: 170,
|
||||||
|
render: (pct: number) => (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(pct, 100)}
|
||||||
|
size="small"
|
||||||
|
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||||
|
showInfo={false}
|
||||||
|
style={{ flex: 1, minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 48 }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rankingColumns: TableColumnsType<RankingRep> = [
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
key: 'rep',
|
||||||
|
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'pedidos',
|
||||||
|
width: 80,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Clientes',
|
||||||
|
dataIndex: 'clientesAtendidos',
|
||||||
|
width: 80,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Faturamento',
|
||||||
|
dataIndex: 'faturamento',
|
||||||
|
width: 150,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => fmt(v),
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Meta',
|
||||||
|
dataIndex: 'pctMeta',
|
||||||
|
width: 160,
|
||||||
|
render: (pct: number) => (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(pct, 100)}
|
||||||
|
size="small"
|
||||||
|
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||||
|
showInfo={false}
|
||||||
|
style={{ flex: 1, minWidth: 60 }}
|
||||||
|
/>
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function PositivacaoDiariaCard({
|
||||||
|
dados,
|
||||||
|
periodo,
|
||||||
|
periodoLabel,
|
||||||
|
isMesAtual,
|
||||||
|
}: {
|
||||||
|
dados: PositivacaoDia[];
|
||||||
|
periodo: Dayjs;
|
||||||
|
periodoLabel: string;
|
||||||
|
isMesAtual: boolean;
|
||||||
|
}) {
|
||||||
|
const [meta, setMeta] = useState<number>(() => {
|
||||||
|
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY));
|
||||||
|
return Number.isFinite(saved) && saved > 0 ? saved : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function changeMeta(v: number | null) {
|
||||||
|
const val = v ?? 0;
|
||||||
|
setMeta(val);
|
||||||
|
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eixo com todos os dias: até hoje no mês atual, mês inteiro nos anteriores
|
||||||
|
const ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date();
|
||||||
|
const porDia = new Map(dados.map((d) => [dayjs(d.dia).date(), d.clientes]));
|
||||||
|
const labels = Array.from({ length: ultimoDia }, (_, i) => String(i + 1));
|
||||||
|
const valores = labels.map((d) => porDia.get(Number(d)) ?? 0);
|
||||||
|
const diasComMeta = meta > 0 ? valores.filter((v) => v >= meta).length : 0;
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'bar' as const,
|
||||||
|
label: 'Clientes positivados',
|
||||||
|
data: valores,
|
||||||
|
backgroundColor: 'rgba(0, 74, 153, 0.8)',
|
||||||
|
borderRadius: 4,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
...(meta > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'line' as const,
|
||||||
|
label: `Meta (${meta}/dia)`,
|
||||||
|
data: labels.map(() => meta),
|
||||||
|
borderColor: '#f5222d',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 4],
|
||||||
|
pointRadius: 0,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index' as const, intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: (items: { label?: string }[]) => `Dia ${items[0]?.label ?? ''}`,
|
||||||
|
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
||||||
|
const val = ctx.parsed.y;
|
||||||
|
if (val == null) return '';
|
||||||
|
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { precision: 0, font: { size: 10 } },
|
||||||
|
grid: { color: '#f0f0f0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faCalendarDay} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Positivação Diária de Clientes — {periodoLabel}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
{meta > 0 && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{diasComMeta} de {ultimoDia} dia{ultimoDia !== 1 ? 's' : ''} na meta
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
Meta/dia:
|
||||||
|
</Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
size="small"
|
||||||
|
value={meta > 0 ? meta : undefined}
|
||||||
|
onChange={changeMeta}
|
||||||
|
placeholder="—"
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ height: 280 }}>
|
||||||
|
<Chart type="bar" data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GerPainel() {
|
||||||
|
const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
|
||||||
|
const mes = periodo.month() + 1;
|
||||||
|
const ano = periodo.year();
|
||||||
|
|
||||||
|
const { data, isLoading } = useManagerDashboard(mes, ano);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const periodoLabel = periodo
|
||||||
|
.toDate()
|
||||||
|
.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||||
|
const isMesAtual = mes === dayjs().month() + 1 && ano === dayjs().year();
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 1 }} />
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Col key={i} xs={24} sm={12} lg={6}>
|
||||||
|
<Skeleton active />
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
faturamentoMes,
|
||||||
|
pedidosMes,
|
||||||
|
ticketMedio,
|
||||||
|
totalReps,
|
||||||
|
promocoesAtivas,
|
||||||
|
metaTotal,
|
||||||
|
metasPorGrupo,
|
||||||
|
rankingReps,
|
||||||
|
positivacaoReps,
|
||||||
|
positivacaoDiaria,
|
||||||
|
syncedAt,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const pctMeta = metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0;
|
||||||
|
const faltaMeta = Math.max(0, metaTotal - faturamentoMes);
|
||||||
|
const superouMeta = faturamentoMes > metaTotal ? faturamentoMes - metaTotal : 0;
|
||||||
|
const metaCorColor =
|
||||||
|
pctMeta >= 100 ? 'var(--green)' : pctMeta >= 70 ? 'var(--jcs-blue)' : '#faad14';
|
||||||
|
|
||||||
|
// Dias restantes no período selecionado (incluindo hoje se for mês atual)
|
||||||
|
const hoje = dayjs();
|
||||||
|
const fimMes = periodo.endOf('month');
|
||||||
|
const diasRestantes = isMesAtual
|
||||||
|
? Math.max(0, fimMes.date() - hoje.date() + 1)
|
||||||
|
: periodo.isBefore(hoje, 'month')
|
||||||
|
? 0
|
||||||
|
: fimMes.date(); // mês futuro: todos os dias
|
||||||
|
const vendaPorDia = faltaMeta > 0 && diasRestantes > 0 ? faltaMeta / diasRestantes : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
{/* Cabeçalho */}
|
||||||
|
<Flex align="flex-start" justify="space-between" wrap="wrap" gap={12}>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
|
Painel Gerencial
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
|
{today()}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
Período:
|
||||||
|
</Text>
|
||||||
|
<DatePicker
|
||||||
|
picker="month"
|
||||||
|
value={periodo}
|
||||||
|
onChange={(d) => d && setPeriodo(d)}
|
||||||
|
format="MMM/YYYY"
|
||||||
|
allowClear={false}
|
||||||
|
disabledDate={(d) => d.isAfter(dayjs(), 'month')}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* KPIs linha 1 */}
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{fmt(faturamentoMes)}
|
||||||
|
</Title>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faFileInvoiceDollar}
|
||||||
|
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{pedidosMes}
|
||||||
|
</Title>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faChartBar}
|
||||||
|
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
TICKET MÉDIO
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{fmt(ticketMedio)}
|
||||||
|
</Title>
|
||||||
|
<FontAwesomeIcon icon={faUsers} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
PROMOÇÕES ATIVAS
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{promocoesAtivas}
|
||||||
|
</Title>
|
||||||
|
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* KPIs linha 2 — Meta */}
|
||||||
|
{metaTotal > 0 && (
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{fmt(metaTotal)}
|
||||||
|
</Title>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faBullseye}
|
||||||
|
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={8}>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
ATINGIMENTO DA META
|
||||||
|
</Text>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faArrowTrendUp}
|
||||||
|
style={{ color: metaCorColor, opacity: 0.7 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Title
|
||||||
|
level={3}
|
||||||
|
style={{ margin: 0, color: metaCorColor }}
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{pctMeta}%
|
||||||
|
</Title>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(pctMeta, 100)}
|
||||||
|
strokeColor={metaCorColor}
|
||||||
|
showInfo={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
|
||||||
|
</Text>
|
||||||
|
<Title
|
||||||
|
level={3}
|
||||||
|
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
|
||||||
|
</Title>
|
||||||
|
{superouMeta > 0 ? (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{pctMeta - 100}% acima da meta
|
||||||
|
</Text>
|
||||||
|
) : vendaPorDia > 0 ? (
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{fmt(vendaPorDia)}/dia — {diasRestantes} dia
|
||||||
|
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
) : diasRestantes === 0 ? (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
período encerrado
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metas por Grupo */}
|
||||||
|
{metasPorGrupo.length > 0 && (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faLayerGroup} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Metas por Grupo — {periodoLabel}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{metasPorGrupo.filter((g: MetaItem) => g.pct >= 100).length} de {metasPorGrupo.length}{' '}
|
||||||
|
grupo
|
||||||
|
{metasPorGrupo.length !== 1 ? 's' : ''} atingido
|
||||||
|
{metasPorGrupo.filter((g: MetaItem) => g.pct >= 100).length !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<MetaItem>
|
||||||
|
rowKey={(r) => String(r.codigo ?? r.rotulo)}
|
||||||
|
columns={metasGrupoColumns}
|
||||||
|
dataSource={metasPorGrupo}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhuma meta por grupo cadastrada.' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ranking */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Ranking de Representantes — {periodoLabel}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Flex align="center" gap={12}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
top 10 de {totalReps} rep{totalReps !== 1 ? 's' : ''} com pedidos
|
||||||
|
</Text>
|
||||||
|
<Button size="small" onClick={() => void navigate({ to: '/ger/equipe' })}>
|
||||||
|
Ver todos
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<RankingRep>
|
||||||
|
rowKey="codVendedor"
|
||||||
|
columns={rankingColumns}
|
||||||
|
dataSource={rankingReps}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum pedido registrado neste período.' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Positivação Diária */}
|
||||||
|
<PositivacaoDiariaCard
|
||||||
|
dados={positivacaoDiaria}
|
||||||
|
periodo={periodo}
|
||||||
|
periodoLabel={periodoLabel}
|
||||||
|
isMesAtual={isMesAtual}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Positivação por Representante */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faAddressCard} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Positivação de Clientes por Representante — {periodoLabel}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<PositivacaoRep>
|
||||||
|
rowKey="codVendedor"
|
||||||
|
columns={positivacaoColumns}
|
||||||
|
dataSource={positivacaoReps}
|
||||||
|
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum dado de carteira encontrado.' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Flex justify="space-between" style={{ paddingTop: 8 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
SAR · Força de Vendas · Powered by JCS Sistemas
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
|
||||||
|
{isMesAtual && ' · atualiza a cada 60s'}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
711
apps/web/src/cockpits/ger/PoliticasPage.tsx
Normal file
711
apps/web/src/cockpits/ger/PoliticasPage.tsx
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DatePicker,
|
||||||
|
Flex,
|
||||||
|
Form,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type {
|
||||||
|
AlcadaDescontoItem,
|
||||||
|
Promocao,
|
||||||
|
RegraDesconto,
|
||||||
|
RepStats,
|
||||||
|
GrupoProdutoItem,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import {
|
||||||
|
usePoliticasDescontos,
|
||||||
|
usePromocoes,
|
||||||
|
useUpsertDesconto,
|
||||||
|
useCreatePromocao,
|
||||||
|
useUpdatePromocao,
|
||||||
|
useDeletePromocao,
|
||||||
|
useEquipe,
|
||||||
|
useRegrasDesconto,
|
||||||
|
useGruposProduto,
|
||||||
|
useCreateRegraDesconto,
|
||||||
|
useUpdateRegraDesconto,
|
||||||
|
useDeleteRegraDesconto,
|
||||||
|
} from '../../lib/queries/gerente';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
// ─── Tab Descontos ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TabDescontos() {
|
||||||
|
const { data, isLoading } = usePoliticasDescontos();
|
||||||
|
const upsert = useUpsertDesconto();
|
||||||
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState<number>(0);
|
||||||
|
const [msg, msgCtx] = message.useMessage();
|
||||||
|
|
||||||
|
function startEdit(row: AlcadaDescontoItem) {
|
||||||
|
setEditingKey(`${row.codVendedor}-${row.codGrupo}`);
|
||||||
|
setEditValue(row.limitePerc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(row: AlcadaDescontoItem) {
|
||||||
|
try {
|
||||||
|
await upsert.mutateAsync({
|
||||||
|
codVendedor: row.codVendedor,
|
||||||
|
codGrupo: row.codGrupo,
|
||||||
|
limitePerc: editValue,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
setEditingKey(null);
|
||||||
|
void msg.success('Limite atualizado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<AlcadaDescontoItem> = [
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
key: 'rep',
|
||||||
|
render: (_: unknown, row: AlcadaDescontoItem) =>
|
||||||
|
row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Grupo',
|
||||||
|
dataIndex: 'codGrupo',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number) => (v === 0 ? <Tag>Global</Tag> : <Tag color="blue">Grupo {v}</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Limite de Desconto',
|
||||||
|
dataIndex: 'limitePerc',
|
||||||
|
width: 200,
|
||||||
|
render: (v: number, row: AlcadaDescontoItem) => {
|
||||||
|
const key = `${row.codVendedor}-${row.codGrupo}`;
|
||||||
|
if (editingKey === key) {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
value={editValue}
|
||||||
|
onChange={(val) => setEditValue(val ?? 0)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
suffix="%"
|
||||||
|
size="small"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Text className="tabular-nums">{v}%</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, row: AlcadaDescontoItem) => {
|
||||||
|
const key = `${row.codVendedor}-${row.codGrupo}`;
|
||||||
|
if (editingKey === key) {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
loading={upsert.isPending}
|
||||||
|
onClick={() => void saveEdit(row)}
|
||||||
|
>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setEditingKey(null)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FontAwesomeIcon icon={faPen} />}
|
||||||
|
onClick={() => startEdit(row)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{msgCtx}
|
||||||
|
<Table<AlcadaDescontoItem>
|
||||||
|
rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.descontos}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhum limite configurado.' }}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
||||||
|
Limite 0 = sem restricao de desconto para este representante.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab Promocoes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PromocaoForm {
|
||||||
|
descricao: string;
|
||||||
|
codProduto?: string;
|
||||||
|
grpProd?: string;
|
||||||
|
descPct: number;
|
||||||
|
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabPromocoes() {
|
||||||
|
const { data, isLoading } = usePromocoes();
|
||||||
|
const createMutation = useCreatePromocao();
|
||||||
|
const updateMutation = useUpdatePromocao();
|
||||||
|
const deleteMutation = useDeletePromocao();
|
||||||
|
const [form] = Form.useForm<PromocaoForm>();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState<Promocao | null>(null);
|
||||||
|
const [msg, msgCtx] = message.useMessage();
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditTarget(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(p: Promocao) {
|
||||||
|
setEditTarget(p);
|
||||||
|
form.setFieldsValue({
|
||||||
|
descricao: p.descricao,
|
||||||
|
codProduto: p.codProduto ?? undefined,
|
||||||
|
grpProd: p.grpProd ?? undefined,
|
||||||
|
descPct: p.descPct,
|
||||||
|
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(values: PromocaoForm) {
|
||||||
|
const [inicio, fim] = values.periodo;
|
||||||
|
const body = {
|
||||||
|
descricao: values.descricao,
|
||||||
|
codProduto: values.codProduto || undefined,
|
||||||
|
grpProd: values.grpProd || undefined,
|
||||||
|
descPct: values.descPct,
|
||||||
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editTarget) {
|
||||||
|
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||||
|
void msg.success('Promocao atualizada');
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(body);
|
||||||
|
void msg.success('Promocao criada');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
void msg.success('Promocao removida');
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const columns: TableColumnsType<Promocao> = [
|
||||||
|
{
|
||||||
|
title: 'Descricao',
|
||||||
|
dataIndex: 'descricao',
|
||||||
|
render: (v: string, row: Promocao) => (
|
||||||
|
<Flex vertical gap={2}>
|
||||||
|
<Text>{v}</Text>
|
||||||
|
{(row.codProduto || row.grpProd) && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
{row.codProduto ? `Produto: ${row.codProduto}` : `Grupo: ${row.grpProd}`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Desconto',
|
||||||
|
dataIndex: 'descPct',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vigencia',
|
||||||
|
width: 200,
|
||||||
|
render: (_: unknown, row: Promocao) => (
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
{dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: Promocao) => {
|
||||||
|
if (!row.ativa) return <Tag>Inativa</Tag>;
|
||||||
|
if (row.dataFim < today) return <Tag color="red">Expirada</Tag>;
|
||||||
|
if (row.dataInicio > today) return <Tag color="blue">Futura</Tag>;
|
||||||
|
return <Tag color="green">Ativa</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, row: Promocao) => (
|
||||||
|
<Space>
|
||||||
|
<Tooltip title="Editar">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FontAwesomeIcon icon={faPen} />}
|
||||||
|
onClick={() => openEdit(row)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="Remover promocao?"
|
||||||
|
okText="Sim"
|
||||||
|
cancelText="Nao"
|
||||||
|
onConfirm={() => void handleDelete(row.id)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{msgCtx}
|
||||||
|
<Flex justify="flex-end" style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<FontAwesomeIcon icon={faPlus} />} onClick={openCreate}>
|
||||||
|
Nova Promocao
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Table<Promocao>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.promocoes}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhuma promocao cadastrada.' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editTarget ? 'Editar Promocao' : 'Nova Promocao'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||||
|
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={520}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="descricao"
|
||||||
|
label="Descricao"
|
||||||
|
rules={[{ required: true, message: 'Informe a descricao' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Ex.: Promocao de inverno" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Flex gap={16}>
|
||||||
|
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}>
|
||||||
|
<Input placeholder="Opcional" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
|
||||||
|
<Input placeholder="Opcional" />
|
||||||
|
</Form.Item>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="descPct"
|
||||||
|
label="Desconto (%)"
|
||||||
|
rules={[{ required: true, message: 'Informe o desconto' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100} suffix="%" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="periodo"
|
||||||
|
label="Periodo de vigencia"
|
||||||
|
rules={[{ required: true, message: 'Informe o periodo' }]}
|
||||||
|
>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
format="DD/MM/YYYY"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={['Inicio', 'Fim']}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab Regras de Desconto ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RegraForm {
|
||||||
|
descricao: string;
|
||||||
|
codVendedor?: number;
|
||||||
|
codGrupo?: number;
|
||||||
|
codSubgrupo?: number;
|
||||||
|
descPct: number;
|
||||||
|
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabRegras() {
|
||||||
|
const { data, isLoading } = useRegrasDesconto();
|
||||||
|
const { data: equipe } = useEquipe();
|
||||||
|
const { data: gruposData } = useGruposProduto();
|
||||||
|
const createMutation = useCreateRegraDesconto();
|
||||||
|
const updateMutation = useUpdateRegraDesconto();
|
||||||
|
const deleteMutation = useDeleteRegraDesconto();
|
||||||
|
const [form] = Form.useForm<RegraForm>();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
|
||||||
|
const [msg, msgCtx] = message.useMessage();
|
||||||
|
const grupoSelecionado = Form.useWatch('codGrupo', form);
|
||||||
|
|
||||||
|
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
|
||||||
|
value: r.codVendedor,
|
||||||
|
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
|
||||||
|
const grupoMap = new Map<number, { value: number; label: string }>();
|
||||||
|
const subgrupoMap = new Map<number, { value: number; label: string }>();
|
||||||
|
for (const g of grupoRows) {
|
||||||
|
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
|
||||||
|
grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
g.codSubgrupo != null &&
|
||||||
|
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) &&
|
||||||
|
!subgrupoMap.has(g.codSubgrupo)
|
||||||
|
) {
|
||||||
|
subgrupoMap.set(g.codSubgrupo, {
|
||||||
|
value: g.codSubgrupo,
|
||||||
|
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const grupoOptions = [...grupoMap.values()];
|
||||||
|
const subgrupoOptions = [...subgrupoMap.values()];
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditTarget(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(r: RegraDesconto) {
|
||||||
|
setEditTarget(r);
|
||||||
|
form.setFieldsValue({
|
||||||
|
descricao: r.descricao,
|
||||||
|
codVendedor: r.codVendedor ?? undefined,
|
||||||
|
codGrupo: r.codGrupo ?? undefined,
|
||||||
|
codSubgrupo: r.codSubgrupo ?? undefined,
|
||||||
|
descPct: r.descPct,
|
||||||
|
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(values: RegraForm) {
|
||||||
|
const [inicio, fim] = values.periodo;
|
||||||
|
const body = {
|
||||||
|
descricao: values.descricao,
|
||||||
|
codVendedor: values.codVendedor ?? null,
|
||||||
|
codGrupo: values.codGrupo ?? null,
|
||||||
|
codSubgrupo: values.codSubgrupo ?? null,
|
||||||
|
descPct: values.descPct,
|
||||||
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editTarget) {
|
||||||
|
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||||
|
void msg.success('Regra atualizada');
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(body);
|
||||||
|
void msg.success('Regra criada');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
void msg.success('Regra removida');
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const columns: TableColumnsType<RegraDesconto> = [
|
||||||
|
{
|
||||||
|
title: 'Descricao',
|
||||||
|
dataIndex: 'descricao',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Representante',
|
||||||
|
width: 180,
|
||||||
|
render: (_: unknown, row: RegraDesconto) =>
|
||||||
|
row.codVendedor == null ? (
|
||||||
|
<Tag>Todos</Tag>
|
||||||
|
) : (
|
||||||
|
<Text>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Grupo / Subgrupo',
|
||||||
|
width: 220,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => {
|
||||||
|
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
|
||||||
|
const partes = [
|
||||||
|
row.codGrupo != null ? (row.grupo ?? `Grupo ${row.codGrupo}`) : null,
|
||||||
|
row.codSubgrupo != null ? (row.subgrupo ?? `Subgrupo ${row.codSubgrupo}`) : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Desconto',
|
||||||
|
dataIndex: 'descPct',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vigencia',
|
||||||
|
width: 200,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => (
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
{dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => {
|
||||||
|
if (!row.ativa) return <Tag>Inativa</Tag>;
|
||||||
|
if (row.dataFim < today) return <Tag color="red">Expirada</Tag>;
|
||||||
|
if (row.dataInicio > today) return <Tag color="blue">Futura</Tag>;
|
||||||
|
return <Tag color="green">Ativa</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => (
|
||||||
|
<Space>
|
||||||
|
<Tooltip title="Editar">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FontAwesomeIcon icon={faPen} />}
|
||||||
|
onClick={() => openEdit(row)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="Remover regra?"
|
||||||
|
okText="Sim"
|
||||||
|
cancelText="Nao"
|
||||||
|
onConfirm={() => void handleDelete(row.id)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{msgCtx}
|
||||||
|
<Flex justify="flex-end" style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<FontAwesomeIcon icon={faPlus} />} onClick={openCreate}>
|
||||||
|
Nova Regra
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Table<RegraDesconto>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.regras}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
||||||
|
A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica
|
||||||
|
o desconto automaticamente nos pedidos durante a vigencia.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editTarget ? 'Editar Regra de Desconto' : 'Nova Regra de Desconto'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||||
|
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={520}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="descricao"
|
||||||
|
label="Descricao"
|
||||||
|
rules={[{ required: true, message: 'Informe a descricao' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Ex.: Campanha linha inverno" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="codVendedor" label="Representante">
|
||||||
|
<Select
|
||||||
|
options={repOptions}
|
||||||
|
placeholder="Todos os representantes"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Flex gap={16}>
|
||||||
|
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||||
|
<Select
|
||||||
|
options={grupoOptions}
|
||||||
|
placeholder="Todos os grupos"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
|
||||||
|
<Select
|
||||||
|
options={subgrupoOptions}
|
||||||
|
placeholder="Todos os subgrupos"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="descPct"
|
||||||
|
label="Desconto (%)"
|
||||||
|
rules={[{ required: true, message: 'Informe o desconto' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={100} suffix="%" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="periodo"
|
||||||
|
label="Periodo de vigencia"
|
||||||
|
rules={[{ required: true, message: 'Informe o periodo' }]}
|
||||||
|
>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
format="DD/MM/YYYY"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={['Inicio', 'Fim']}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PoliticasPage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function PoliticasPage() {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: 'descontos',
|
||||||
|
label: 'Desconto por Representante',
|
||||||
|
children: <TabDescontos />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'promocoes',
|
||||||
|
label: 'Promocoes',
|
||||||
|
children: <TabPromocoes />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'regras',
|
||||||
|
label: 'Regras de Desconto',
|
||||||
|
children: <TabRegras />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
|
Politicas Comerciais
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
|
Limites de desconto e promocoes com vigencia
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Card styles={{ body: { paddingTop: 0 } }}>
|
||||||
|
<Tabs items={items} />
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
apps/web/src/cockpits/rep/AbrirChamadoModal.tsx
Normal file
159
apps/web/src/cockpits/rep/AbrirChamadoModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, Form, Select, Input, Checkbox, Typography, Space } from 'antd';
|
||||||
|
import { TIPOS_CHAMADO, TIPO_CHAMADO_LABEL, type PedidoItem } from '@sar/api-interface';
|
||||||
|
import { useCreateChamado } from '../../lib/queries/sac';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
idPedido?: string;
|
||||||
|
numPedSar?: number;
|
||||||
|
numPedErp?: number;
|
||||||
|
nomeCliente?: string | null;
|
||||||
|
idCliente: number;
|
||||||
|
itens: PedidoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AbrirChamadoModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
idPedido,
|
||||||
|
numPedSar,
|
||||||
|
numPedErp,
|
||||||
|
nomeCliente,
|
||||||
|
idCliente,
|
||||||
|
itens,
|
||||||
|
}: Props) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [itensSelecionados, setItensSelecionados] = useState<string[]>([]);
|
||||||
|
const criar = useCreateChamado();
|
||||||
|
|
||||||
|
const pedidoLabel = numPedErp
|
||||||
|
? `ERP #${numPedErp}`
|
||||||
|
: numPedSar
|
||||||
|
? `SAR-${String(numPedSar).padStart(5, '0')}`
|
||||||
|
: 'Pedido';
|
||||||
|
|
||||||
|
function handleOk() {
|
||||||
|
form.validateFields().then((values) => {
|
||||||
|
const itensAfetados = itens
|
||||||
|
.filter((it) => itensSelecionados.includes(it.id))
|
||||||
|
.map((it) => ({
|
||||||
|
codProduto: it.codProduto ?? '',
|
||||||
|
nomeProduto: it.descProduto ?? it.codProduto ?? '',
|
||||||
|
qtd: Number(it.qtd),
|
||||||
|
}));
|
||||||
|
criar.mutate(
|
||||||
|
{
|
||||||
|
idPedido,
|
||||||
|
numPedSar,
|
||||||
|
numPedErp,
|
||||||
|
nomeCliente: nomeCliente ?? undefined,
|
||||||
|
idCliente,
|
||||||
|
tipo: values.tipo,
|
||||||
|
assunto: values.assunto,
|
||||||
|
descricao: values.descricao,
|
||||||
|
itensAfetados,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
form.resetFields();
|
||||||
|
setItensSelecionados([]);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
form.resetFields();
|
||||||
|
setItensSelecionados([]);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
Abrir Chamado — {pedidoLabel}
|
||||||
|
{nomeCliente && (
|
||||||
|
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 8 }}>
|
||||||
|
· {nomeCliente}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
okText="Abrir Chamado"
|
||||||
|
cancelText="Cancelar"
|
||||||
|
confirmLoading={criar.isPending}
|
||||||
|
width={640}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="tipo"
|
||||||
|
label="Tipo de Ocorrência"
|
||||||
|
rules={[{ required: true, message: 'Selecione o tipo' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="Selecione...">
|
||||||
|
{TIPOS_CHAMADO.map((t) => (
|
||||||
|
<Select.Option key={t} value={t}>
|
||||||
|
{TIPO_CHAMADO_LABEL[t]}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="assunto"
|
||||||
|
label="Assunto"
|
||||||
|
rules={[{ required: true, min: 5, message: 'Mínimo 5 caracteres' }]}
|
||||||
|
>
|
||||||
|
<Input maxLength={200} showCount placeholder="Resumo breve do problema..." />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="descricao"
|
||||||
|
label="Descrição"
|
||||||
|
rules={[{ required: true, min: 10, message: 'Mínimo 10 caracteres' }]}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
maxLength={2000}
|
||||||
|
showCount
|
||||||
|
placeholder="Descreva detalhadamente o problema..."
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{itens.length > 0 && (
|
||||||
|
<Form.Item label="Produtos Afetados (opcional)">
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||||
|
{itens.map((it) => (
|
||||||
|
<Checkbox
|
||||||
|
key={it.id}
|
||||||
|
checked={itensSelecionados.includes(it.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setItensSelecionados((prev) => [...prev, it.id]);
|
||||||
|
} else {
|
||||||
|
setItensSelecionados((prev) => prev.filter((c) => c !== it.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
{it.codProduto} — {it.descProduto ?? it.codProduto}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary"> (Qtd: {it.qtd})</Text>
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
398
apps/web/src/cockpits/rep/CarteirePage.tsx
Normal file
398
apps/web/src/cockpits/rep/CarteirePage.tsx
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Flex,
|
||||||
|
Row,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
FireOutlined,
|
||||||
|
RiseOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import type { CarteiraCliente } from '@sar/api-interface';
|
||||||
|
import { useReportCarteira } from '../../lib/queries/reports';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
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 + 'T00:00:00').toLocaleDateString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
type Filtro = 'todos' | 'ativos' | 'risco' | 'inativos' | 'semPedido';
|
||||||
|
|
||||||
|
function statusBadge(c: CarteiraCliente) {
|
||||||
|
if (c.totalPedidos === 0)
|
||||||
|
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedido</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' }}>Em risco 30d+</Text>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsightCard({
|
||||||
|
icon,
|
||||||
|
cor,
|
||||||
|
titulo,
|
||||||
|
descricao,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
cor: string;
|
||||||
|
titulo: string;
|
||||||
|
descricao: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
styles={{ body: { padding: '14px 18px' } }}
|
||||||
|
style={{ borderLeft: `4px solid ${cor}`, borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<Flex gap={12} align="flex-start">
|
||||||
|
<span style={{ color: cor, fontSize: 18, marginTop: 2 }}>{icon}</span>
|
||||||
|
{/* minWidth: 0 — flex item precisa poder encolher; titulo/descricao
|
||||||
|
embutem razão social e valores que podem não ter ponto de quebra. */}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1e293b' }}>
|
||||||
|
{titulo}
|
||||||
|
</Text>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{descricao}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarteirePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, isError, error } = useReportCarteira();
|
||||||
|
const [filtro, setFiltro] = useState<Filtro>('todos');
|
||||||
|
|
||||||
|
const clientes = data?.data ?? [];
|
||||||
|
|
||||||
|
const ativos = clientes.filter(
|
||||||
|
(c) => c.totalPedidos > 0 && (c.diasSemPedido == null || c.diasSemPedido < 30),
|
||||||
|
);
|
||||||
|
const emRisco = clientes.filter(
|
||||||
|
(c) => c.diasSemPedido != null && c.diasSemPedido >= 30 && c.diasSemPedido < 60,
|
||||||
|
);
|
||||||
|
const inativos = clientes.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60);
|
||||||
|
const semPedido = clientes.filter((c) => c.totalPedidos === 0);
|
||||||
|
|
||||||
|
const filtered = (() => {
|
||||||
|
if (filtro === 'ativos') return ativos;
|
||||||
|
if (filtro === 'risco') return emRisco;
|
||||||
|
if (filtro === 'inativos') return inativos;
|
||||||
|
if (filtro === 'semPedido') return semPedido;
|
||||||
|
return clientes;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Insights calculados
|
||||||
|
const top5Fat = clientes
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal))
|
||||||
|
.slice(0, 5);
|
||||||
|
const top5Pct = top5Fat.reduce((acc, c) => acc + c.participacaoPct, 0);
|
||||||
|
|
||||||
|
const maiorCliEmRisco = emRisco.sort(
|
||||||
|
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
|
||||||
|
)[0];
|
||||||
|
const maiorCliInativo = inativos.sort(
|
||||||
|
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const insights: { icon: React.ReactNode; cor: string; titulo: string; descricao: string }[] = [];
|
||||||
|
|
||||||
|
if (top5Pct >= 60) {
|
||||||
|
insights.push({
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
cor: '#f59e0b',
|
||||||
|
titulo: `Concentração: top 5 clientes = ${top5Pct.toFixed(0)}% do faturamento`,
|
||||||
|
descricao: 'Risco alto de dependência. Considere expandir outros clientes da carteira.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maiorCliEmRisco) {
|
||||||
|
const nome =
|
||||||
|
maiorCliEmRisco.razao ?? maiorCliEmRisco.nome ?? `Cliente ${maiorCliEmRisco.idCliente}`;
|
||||||
|
insights.push({
|
||||||
|
icon: <WarningOutlined />,
|
||||||
|
cor: '#f59e0b',
|
||||||
|
titulo: `${nome} está ${maiorCliEmRisco.diasSemPedido}d sem comprar`,
|
||||||
|
descricao: `Faturamento histórico: ${fmt(maiorCliEmRisco.faturamentoTotal)} — priorize o contato.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maiorCliInativo) {
|
||||||
|
const nome =
|
||||||
|
maiorCliInativo.razao ?? maiorCliInativo.nome ?? `Cliente ${maiorCliInativo.idCliente}`;
|
||||||
|
insights.push({
|
||||||
|
icon: <FireOutlined />,
|
||||||
|
cor: '#ef4444',
|
||||||
|
titulo: `${nome} inativo há ${maiorCliInativo.diasSemPedido}d`,
|
||||||
|
descricao: `Era um cliente de ${fmt(maiorCliInativo.faturamentoTotal)} — vale reativar.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (semPedido.length > 0) {
|
||||||
|
insights.push({
|
||||||
|
icon: <RiseOutlined />,
|
||||||
|
cor: '#003B8E',
|
||||||
|
titulo: `${semPedido.length} cliente${semPedido.length > 1 ? 's' : ''} sem nenhum pedido`,
|
||||||
|
descricao: 'Potencial inexplorado na sua carteira. Primeira visita pode gerar receita nova.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<CarteiraCliente> = [
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
key: 'cliente',
|
||||||
|
render: (_: unknown, c: CarteiraCliente) => (
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ fontSize: 13, cursor: 'pointer', color: '#003B8E' }}
|
||||||
|
onClick={() => navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => (a.ultimoPedido ?? '').localeCompare(b.ultimoPedido ?? ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Dias Sem Compra',
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => (a.diasSemPedido ?? 9999) - (b.diasSemPedido ?? 9999),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Faturado (total)',
|
||||||
|
dataIndex: 'faturamentoTotal',
|
||||||
|
width: 150,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => Number(a.faturamentoTotal) - Number(b.faturamentoTotal),
|
||||||
|
defaultSortOrder: 'descend' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Participação',
|
||||||
|
dataIndex: 'participacaoPct',
|
||||||
|
width: 110,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => (
|
||||||
|
<Tag
|
||||||
|
color={v >= 10 ? 'blue' : v >= 5 ? 'geekblue' : 'default'}
|
||||||
|
style={{ borderRadius: 20, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{v.toFixed(1)}%
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => a.participacaoPct - b.participacaoPct,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ticket Médio',
|
||||||
|
dataIndex: 'ticketMedio',
|
||||||
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text className="tabular-nums" style={{ color: '#475569' }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => Number(a.ticketMedio) - Number(b.ticketMedio),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'totalPedidos',
|
||||||
|
width: 80,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
|
||||||
|
sorter: (a, b) => a.totalPedidos - b.totalPedidos,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtros: { key: Filtro; label: string; count: number; cor: string }[] = [
|
||||||
|
{ key: 'todos', label: 'Todos', count: clientes.length, cor: '#64748b' },
|
||||||
|
{ key: 'ativos', label: 'Ativos', count: ativos.length, cor: '#22c55e' },
|
||||||
|
{ key: 'risco', label: 'Em risco (30d+)', count: emRisco.length, cor: '#f59e0b' },
|
||||||
|
{ key: 'inativos', label: 'Inativos (60d+)', count: inativos.length, cor: '#ef4444' },
|
||||||
|
{ key: 'semPedido', label: 'Sem pedido', count: semPedido.length, cor: '#94A3B8' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||||
|
{/* Cabeçalho */}
|
||||||
|
<Flex align="center" gap={12} style={{ marginBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate({ to: '/clientes' })}
|
||||||
|
type="text"
|
||||||
|
style={{ color: '#64748b' }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
<TeamOutlined style={{ marginRight: 8 }} />
|
||||||
|
Carteira de Clientes
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Visão completa da sua carteira com indicadores de saúde e oportunidades
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Erro ao carregar carteira"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 10 }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Cards de resumo */}
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||||
|
{filtros.map((f) => (
|
||||||
|
<Col key={f.key} xs={12} sm={8} md={4} style={{ minWidth: 120 }}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={() => setFiltro(f.key)}
|
||||||
|
styles={{ body: { padding: '12px 16px' } }}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: filtro === f.key ? `2px solid ${f.cor}` : '1px solid #EBF0F5',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={f.label}
|
||||||
|
value={f.count}
|
||||||
|
styles={{ content: { color: f.cor, fontSize: 24 } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
{data && (
|
||||||
|
<Col xs={12} sm={8} md={6}>
|
||||||
|
<Card styles={{ body: { padding: '12px 16px' } }} style={{ borderRadius: 8 }}>
|
||||||
|
<Statistic
|
||||||
|
title="Faturamento Total (ERP)"
|
||||||
|
value={fmt(data.faturamentoRepTotal)}
|
||||||
|
styles={{ content: { color: '#003B8E', fontSize: 18 } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Insights */}
|
||||||
|
{insights.length > 0 && (
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||||
|
{insights.map((ins, i) => (
|
||||||
|
<Col key={i} xs={24} md={12}>
|
||||||
|
<InsightCard {...ins} />
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabela */}
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<CarteiraCliente>
|
||||||
|
rowKey="idCliente"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filtered}
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
style={{ borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
onRow={(c) => ({
|
||||||
|
onClick: () =>
|
||||||
|
navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } }),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Table, Input, Select, Space, Typography, Tag } from 'antd';
|
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
|
||||||
|
import { EyeOutlined } from '@ant-design/icons';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import type { ProdutoSummary } from '@sar/api-interface';
|
import type { ProdutoSummary } from '@sar/api-interface';
|
||||||
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
||||||
|
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
|
||||||
|
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||||
|
import { ProductDetailDrawer } from './ProductDetailDrawer';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
|
|
||||||
function fmtPrice(v: string | null | undefined): string {
|
function fmtPrice(v: string | null | undefined): string {
|
||||||
@@ -12,7 +18,11 @@ function fmtPrice(v: string | null | undefined): string {
|
|||||||
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
|
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: TableColumnsType<ProdutoSummary> = [
|
function buildColumns(
|
||||||
|
onDetail: (id: number) => void,
|
||||||
|
condicoesDe: (p: ProdutoSummary) => CondicaoComercial[],
|
||||||
|
): TableColumnsType<ProdutoSummary> {
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
title: 'Código',
|
title: 'Código',
|
||||||
dataIndex: 'codigo',
|
dataIndex: 'codigo',
|
||||||
@@ -24,7 +34,18 @@ const columns: TableColumnsType<ProdutoSummary> = [
|
|||||||
dataIndex: 'descricao',
|
dataIndex: 'descricao',
|
||||||
render: (v: string, row: ProdutoSummary) => (
|
render: (v: string, row: ProdutoSummary) => (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500 }}>{v.trim()}</div>
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v.trim()}
|
||||||
|
<CondicaoTag conds={condicoesDe(row)} compact />
|
||||||
|
</div>
|
||||||
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
|
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -66,28 +87,58 @@ const columns: TableColumnsType<ProdutoSummary> = [
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 48,
|
||||||
|
align: 'center',
|
||||||
|
render: (_: unknown, row: ProdutoSummary) => (
|
||||||
|
<Tooltip title="Ver detalhes">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => onDetail(row.idErp)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function CatalogPage() {
|
export function CatalogPage() {
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [idPauta, setIdPauta] = useState<number | undefined>();
|
const [idPauta, setIdPauta] = useState<number | undefined>();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedIdErp, setSelectedIdErp] = useState<number | null>(null);
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
const { data: pautas, isLoading: pautasLoading } = usePautas();
|
const { data: pautas, isLoading: pautasLoading } = usePautas();
|
||||||
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
|
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
|
||||||
|
const condicoesDe = useCondicoesComerciais();
|
||||||
|
|
||||||
|
const columns = buildColumns((id) => setSelectedIdErp(id), condicoesDe);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||||
<Title level={3} style={{ marginBottom: 16 }}>
|
{/* ── Cabeçalho ────────────────────────────────────────────────── */}
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
Catálogo de Produtos
|
Catálogo de Produtos
|
||||||
</Title>
|
</Title>
|
||||||
|
<Text style={{ color: '#64748B', fontSize: 14 }}>
|
||||||
|
Consulte produtos, preços por pauta e disponibilidade de estoque.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
{/* ── Filtros ──────────────────────────────────────────────────── */}
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%' }} wrap>
|
||||||
<Search
|
<Search
|
||||||
placeholder="Buscar por código ou descrição..."
|
placeholder="Buscar por código ou descrição..."
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 300 }}
|
style={{ width: isMobile ? '100%' : 300 }}
|
||||||
onSearch={(v) => {
|
onSearch={(v) => {
|
||||||
setQ(v);
|
setQ(v);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -103,7 +154,7 @@ export function CatalogPage() {
|
|||||||
placeholder="Selecionar pauta de preços"
|
placeholder="Selecionar pauta de preços"
|
||||||
allowClear
|
allowClear
|
||||||
loading={pautasLoading}
|
loading={pautasLoading}
|
||||||
style={{ width: 340 }}
|
style={{ width: isMobile ? '100%' : 340 }}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setIdPauta(v as number | undefined);
|
setIdPauta(v as number | undefined);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -129,7 +180,13 @@ export function CatalogPage() {
|
|||||||
showTotal: (t) => `${t.toLocaleString('pt-BR')} produtos`,
|
showTotal: (t) => `${t.toLocaleString('pt-BR')} produtos`,
|
||||||
onChange: (p) => setPage(p),
|
onChange: (p) => setPage(p),
|
||||||
}}
|
}}
|
||||||
|
onRow={(row) => ({
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
onDoubleClick: () => setSelectedIdErp(row.idErp),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProductDetailDrawer idErp={selectedIdErp} onClose={() => setSelectedIdErp(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
432
apps/web/src/cockpits/rep/ChamadosPage.tsx
Normal file
432
apps/web/src/cockpits/rep/ChamadosPage.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge, Button, Modal, Input, Space, Spin, Table, Tag, Typography, Tabs, Form } from 'antd';
|
||||||
|
import { CustomerServiceOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import {
|
||||||
|
STATUS_CHAMADO_LABEL,
|
||||||
|
TIPO_CHAMADO_LABEL,
|
||||||
|
RESOLUCAO_CHAMADO_LABEL,
|
||||||
|
type Chamado,
|
||||||
|
type ItemAfetado,
|
||||||
|
type ChamadoMensagem,
|
||||||
|
type PedidoErpConsultaItem,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import {
|
||||||
|
useChamados,
|
||||||
|
useAddMensagemRep,
|
||||||
|
useCancelChamado,
|
||||||
|
useChamado,
|
||||||
|
} from '../../lib/queries/sac';
|
||||||
|
import { useOrderErpConsulta, useOrderDetail } from '../../lib/queries/orders';
|
||||||
|
import { AbrirChamadoModal } from './AbrirChamadoModal';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
aberto: 'blue',
|
||||||
|
em_analise: 'orange',
|
||||||
|
aguardando_rep: 'purple',
|
||||||
|
resolvido: 'green',
|
||||||
|
cancelado: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
function pedidoLabel(chamado: Chamado) {
|
||||||
|
if (chamado.numPedErp) return `ERP #${chamado.numPedErp}`;
|
||||||
|
if (chamado.numPedSar) return `SAR-${String(chamado.numPedSar).padStart(5, '0')}`;
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca pedido ERP por texto livre (cliente, número, etc.) e abre chamado
|
||||||
|
function BuscarPedidoModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [searchAtivo, setSearchAtivo] = useState('');
|
||||||
|
const [pedidoSelecionado, setPedidoSelecionado] = useState<PedidoErpConsultaItem | null>(null);
|
||||||
|
|
||||||
|
// idPedido é o ID interno do ERP (usado no endpoint /orders/erp/:id)
|
||||||
|
// numeroPedido é o número externo visível ao usuário
|
||||||
|
const erp_id = pedidoSelecionado?.idPedido ?? null;
|
||||||
|
const consulta = useOrderErpConsulta({ search: searchAtivo, limit: 20 }, !!searchAtivo);
|
||||||
|
const detalhe = useOrderDetail(erp_id ? `erp-${erp_id}` : undefined);
|
||||||
|
|
||||||
|
function buscar() {
|
||||||
|
const trimmed = searchInput.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setSearchAtivo(trimmed);
|
||||||
|
setPedidoSelecionado(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fechar() {
|
||||||
|
setSearchInput('');
|
||||||
|
setSearchAtivo('');
|
||||||
|
setPedidoSelecionado(null);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detalhe carregado — abre o formulário de chamado
|
||||||
|
if (detalhe.data && pedidoSelecionado) {
|
||||||
|
return (
|
||||||
|
<AbrirChamadoModal
|
||||||
|
open={true}
|
||||||
|
onClose={fechar}
|
||||||
|
numPedErp={pedidoSelecionado.numeroPedido}
|
||||||
|
nomeCliente={pedidoSelecionado.razaoCliente ?? pedidoSelecionado.nomeCliente}
|
||||||
|
idCliente={pedidoSelecionado.idCliente}
|
||||||
|
itens={detalhe.data.itens}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultados = consulta.data?.data ?? [];
|
||||||
|
const carregandoDetalhe = detalhe.isLoading;
|
||||||
|
|
||||||
|
const colunas: TableColumnsType<PedidoErpConsultaItem> = [
|
||||||
|
{ title: 'Pedido', dataIndex: 'numeroPedido', width: 90 },
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
render: (_: unknown, r: PedidoErpConsultaItem) => r.razaoCliente ?? r.nomeCliente ?? '—',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Data',
|
||||||
|
dataIndex: 'data',
|
||||||
|
width: 95,
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) =>
|
||||||
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Novo Chamado — Selecionar Pedido"
|
||||||
|
open={open}
|
||||||
|
onCancel={fechar}
|
||||||
|
footer={null}
|
||||||
|
width={680}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" style={{ width: '100%', marginTop: 16 }} size="middle">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="Buscar por cliente, número do pedido..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onPressEnter={buscar}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={buscar}
|
||||||
|
loading={consulta.isLoading}
|
||||||
|
disabled={!searchInput.trim()}
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
|
||||||
|
{searchAtivo && (
|
||||||
|
<Table<PedidoErpConsultaItem>
|
||||||
|
rowKey="numeroPedido"
|
||||||
|
columns={colunas}
|
||||||
|
dataSource={resultados}
|
||||||
|
loading={consulta.isLoading || carregandoDetalhe}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ y: 320 }}
|
||||||
|
onRow={(r) => ({
|
||||||
|
onClick: () => (r.idPedido ? setPedidoSelecionado(r) : undefined),
|
||||||
|
style: {
|
||||||
|
cursor: r.idPedido ? 'pointer' : 'not-allowed',
|
||||||
|
opacity: r.idPedido ? 1 : 0.4,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChamadoDetalheModal({
|
||||||
|
id,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [texto, setTexto] = useState('');
|
||||||
|
const { data: chamado, isLoading } = useChamado(id);
|
||||||
|
const addMsg = useAddMensagemRep(id);
|
||||||
|
const cancel = useCancelChamado();
|
||||||
|
|
||||||
|
function enviar() {
|
||||||
|
if (!texto.trim()) return;
|
||||||
|
addMsg.mutate({ texto: texto.trim() }, { onSuccess: () => setTexto('') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fechado = chamado && ['resolvido', 'cancelado'].includes(chamado.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={chamado ? `Chamado #${chamado.id} — ${chamado.assunto}` : 'Chamado'}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={700}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{isLoading || !chamado ? (
|
||||||
|
<Spin />
|
||||||
|
) : (
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color={STATUS_COLOR[chamado.status]}>{STATUS_CHAMADO_LABEL[chamado.status]}</Tag>
|
||||||
|
<Tag>{TIPO_CHAMADO_LABEL[chamado.tipo] ?? chamado.tipo}</Tag>
|
||||||
|
{chamado.resolucao && (
|
||||||
|
<Tag color="green">
|
||||||
|
{RESOLUCAO_CHAMADO_LABEL[chamado.resolucao] ?? chamado.resolucao}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{pedidoLabel(chamado)}
|
||||||
|
{chamado.nomeCliente ? ` · ${chamado.nomeCliente}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Text type="secondary">{chamado.descricao}</Text>
|
||||||
|
|
||||||
|
{chamado.notaResolucao && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f6ffed',
|
||||||
|
border: '1px solid #b7eb8f',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '8px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong>Resolução: </Text>
|
||||||
|
<Text>{chamado.notaResolucao}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chamado.itensAfetados && chamado.itensAfetados.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Text strong style={{ fontSize: 12 }}>
|
||||||
|
Produtos afetados:{' '}
|
||||||
|
</Text>
|
||||||
|
{(chamado.itensAfetados as ItemAfetado[]).map((it, i) => (
|
||||||
|
<Tag key={`${it.codProduto}-${i}`}>
|
||||||
|
{it.codProduto} — {it.nomeProduto} ({it.qtd} un)
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
maxHeight: 320,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(chamado.mensagens ?? []).length === 0 ? (
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ textAlign: 'center', display: 'block', padding: '16px 0' }}
|
||||||
|
>
|
||||||
|
Sem mensagens ainda.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
((chamado.mensagens as ChamadoMensagem[]) ?? []).map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: m.autor === 'rep' ? 'flex-end' : 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '72%',
|
||||||
|
background: m.autor === 'rep' ? '#1677ff' : '#f5f5f5',
|
||||||
|
color: m.autor === 'rep' ? '#fff' : '#000',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 2 }}>
|
||||||
|
{m.autor === 'rep' ? 'Você' : 'Empresa'}
|
||||||
|
</div>
|
||||||
|
<div>{m.texto}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4, textAlign: 'right' }}>
|
||||||
|
{new Date(m.createdAt).toLocaleString('pt-BR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!fechado && (
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="Nova mensagem">
|
||||||
|
<TextArea
|
||||||
|
rows={2}
|
||||||
|
value={texto}
|
||||||
|
onChange={(e) => setTexto(e.target.value)}
|
||||||
|
maxLength={2000}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={enviar}
|
||||||
|
loading={addMsg.isPending}
|
||||||
|
disabled={!texto.trim()}
|
||||||
|
>
|
||||||
|
Enviar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
onClick={() => cancel.mutate(id, { onSuccess: onClose })}
|
||||||
|
loading={cancel.isPending}
|
||||||
|
>
|
||||||
|
Cancelar Chamado
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<Chamado> = [
|
||||||
|
{ title: '#', dataIndex: 'id', width: 60 },
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 140,
|
||||||
|
render: (s: string) => (
|
||||||
|
<Tag color={STATUS_COLOR[s] ?? 'default'}>{STATUS_CHAMADO_LABEL[s] ?? s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tipo',
|
||||||
|
dataIndex: 'tipo',
|
||||||
|
width: 160,
|
||||||
|
render: (t: string) => TIPO_CHAMADO_LABEL[t] ?? t,
|
||||||
|
},
|
||||||
|
{ title: 'Assunto', dataIndex: 'assunto', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: 'Pedido',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, r: Chamado) => pedidoLabel(r),
|
||||||
|
},
|
||||||
|
{ title: 'Cliente', dataIndex: 'nomeCliente', width: 160, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: 'Data',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ChamadosPage() {
|
||||||
|
const { data, isLoading } = useChamados();
|
||||||
|
const [detalheId, setDetalheId] = useState<number | null>(null);
|
||||||
|
const [novoOpen, setNovoOpen] = useState(false);
|
||||||
|
|
||||||
|
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||||
|
|
||||||
|
const abertos = data?.abertos ?? [];
|
||||||
|
const fechados = data?.fechados ?? [];
|
||||||
|
const totalAbertos = abertos.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: 1100, margin: '0 auto' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space align="center">
|
||||||
|
<CustomerServiceOutlined style={{ fontSize: 24, color: '#1677ff' }} />
|
||||||
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
|
SAC — Chamados
|
||||||
|
</Title>
|
||||||
|
{totalAbertos > 0 && <Badge count={totalAbertos} />}
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNovoOpen(true)}>
|
||||||
|
Novo Chamado
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'abertos',
|
||||||
|
label: `Abertos (${abertos.length})`,
|
||||||
|
children: (
|
||||||
|
<Table<Chamado>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={abertos}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||||
|
locale={{ emptyText: 'Nenhum chamado aberto.' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fechados',
|
||||||
|
label: `Fechados (${fechados.length})`,
|
||||||
|
children: (
|
||||||
|
<Table<Chamado>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={fechados}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
size="small"
|
||||||
|
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||||
|
locale={{ emptyText: 'Nenhum chamado fechado.' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{detalheId !== null && (
|
||||||
|
<ChamadoDetalheModal
|
||||||
|
id={detalheId}
|
||||||
|
open={detalheId !== null}
|
||||||
|
onClose={() => setDetalheId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BuscarPedidoModal open={novoOpen} onClose={() => setNovoOpen(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,38 @@
|
|||||||
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Grid,
|
||||||
|
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';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const ACTIVITY_COLOR: Record<string, string> = {
|
const ACTIVITY_COLOR: Record<string, string> = {
|
||||||
active: 'success',
|
active: 'success',
|
||||||
@@ -19,11 +45,19 @@ const ACTIVITY_LABEL: Record<string, string> = {
|
|||||||
inactive: 'Inativo',
|
inactive: 'Inativo',
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderColumns: TableColumnsType<PedidoSummary> = [
|
const SITUA_COLOR: Record<number, string> = {
|
||||||
|
0: 'default',
|
||||||
|
1: 'warning',
|
||||||
|
2: 'processing',
|
||||||
|
3: 'error',
|
||||||
|
4: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
function orderColumns(withFonte: boolean): TableColumnsType<PedidoSummary> {
|
||||||
|
const cols: TableColumnsType<PedidoSummary> = [
|
||||||
{
|
{
|
||||||
title: 'Nº',
|
title: 'Nº Pedido',
|
||||||
dataIndex: 'numPedSar',
|
dataIndex: 'numPedSar',
|
||||||
width: 120,
|
|
||||||
render: (num: string, row: PedidoSummary) => (
|
render: (num: string, row: PedidoSummary) => (
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
{num}
|
{num}
|
||||||
@@ -33,46 +67,162 @@ const orderColumns: TableColumnsType<PedidoSummary> = [
|
|||||||
{
|
{
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
dataIndex: 'situa',
|
dataIndex: 'situa',
|
||||||
width: 140,
|
width: 130,
|
||||||
render: (s: number) => {
|
render: (s: number) => (
|
||||||
const colorMap: Record<number, string> = {
|
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
|
||||||
1: 'warning',
|
),
|
||||||
2: 'processing',
|
|
||||||
3: 'error',
|
|
||||||
4: 'success',
|
|
||||||
};
|
|
||||||
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total',
|
title: 'Total',
|
||||||
dataIndex: 'total',
|
dataIndex: 'total',
|
||||||
width: 130,
|
width: 130,
|
||||||
align: 'right',
|
align: 'right' as const,
|
||||||
render: (v: string) =>
|
render: (v: string) =>
|
||||||
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Data',
|
title: 'Data',
|
||||||
dataIndex: 'dtPedido',
|
dataIndex: 'dtPedido',
|
||||||
width: 130,
|
width: 110,
|
||||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
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 screens = useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
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 }}>
|
||||||
@@ -81,6 +231,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 } })}
|
||||||
@@ -89,7 +242,7 @@ export function ClientDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
<Descriptions bordered size="small" column={isMobile ? 1 : 2} style={{ marginBottom: 24 }}>
|
||||||
<Descriptions.Item label="Razão Social">{client.nome}</Descriptions.Item>
|
<Descriptions.Item label="Razão Social">{client.nome}</Descriptions.Item>
|
||||||
<Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
|
||||||
@@ -98,7 +251,7 @@ export function ClientDetailPage() {
|
|||||||
{client.telefone ?? '—'}
|
{client.telefone ?? '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
{client.endereco && (
|
{client.endereco && (
|
||||||
<Descriptions.Item label="Endereço" span={2}>
|
<Descriptions.Item label="Endereço" span={isMobile ? 1 : 2}>
|
||||||
{client.endereco}
|
{client.endereco}
|
||||||
{client.numEndereco ? `, ${client.numEndereco}` : ''}
|
{client.numEndereco ? `, ${client.numEndereco}` : ''}
|
||||||
{client.bairro ? ` — ${client.bairro}` : ''}
|
{client.bairro ? ` — ${client.bairro}` : ''}
|
||||||
@@ -120,18 +273,315 @@ export function ClientDetailPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Divider orientation="left">Últimos Pedidos</Divider>
|
{/* ── CTR + NF-e lado a lado ── */}
|
||||||
|
{msgCtx}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Contas a Receber ── */}
|
||||||
|
{/* minWidth: 0 — track 1fr tem min-width auto; sem isto a tabela com
|
||||||
|
scroll x 'max-content' estoura a coluna e engole a vizinha (NF-e). */}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* ── Notas Fiscais ── */}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<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={isMobile ? '95vw' : 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={isMobile ? '95vw' : 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={isMobile ? '95vw' : 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
Drawer,
|
Modal,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Grid,
|
Grid,
|
||||||
Row,
|
Row,
|
||||||
@@ -186,7 +186,13 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
|
|||||||
|
|
||||||
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
|
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {
|
function CustomerPortfolioCard({
|
||||||
|
stats,
|
||||||
|
onDetalhar,
|
||||||
|
}: {
|
||||||
|
stats: PortfolioStats;
|
||||||
|
onDetalhar: () => void;
|
||||||
|
}) {
|
||||||
const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||||
const total = stats.ativos + stats.emAlerta + stats.inativos;
|
const total = stats.ativos + stats.emAlerta + stats.inativos;
|
||||||
|
|
||||||
@@ -277,7 +283,7 @@ function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space direction="vertical" size={6} style={{ width: '100%', marginBottom: 16 }}>
|
<Space orientation="vertical" size={6} style={{ width: '100%', marginBottom: 16 }}>
|
||||||
{legendItems.map((item) => {
|
{legendItems.map((item) => {
|
||||||
const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0';
|
const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0';
|
||||||
return (
|
return (
|
||||||
@@ -313,6 +319,7 @@ function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {
|
|||||||
<Divider style={{ margin: '12px 0' }} />
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
<Button
|
<Button
|
||||||
block
|
block
|
||||||
|
onClick={onDetalhar}
|
||||||
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
|
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
|
||||||
>
|
>
|
||||||
Detalhar carteira
|
Detalhar carteira
|
||||||
@@ -397,9 +404,9 @@ function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── CustomerDetailsDrawer ────────────────────────────────────────────────────
|
// ─── CustomerDetailsModal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CustomerDetailsDrawer({
|
function CustomerDetailsModal({
|
||||||
summary,
|
summary,
|
||||||
onClose,
|
onClose,
|
||||||
onAnalyze,
|
onAnalyze,
|
||||||
@@ -409,6 +416,8 @@ function CustomerDetailsDrawer({
|
|||||||
onAnalyze: () => void;
|
onAnalyze: () => void;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const { data: detail, isLoading: loadingDetail } = useClientDetail(summary?.idCliente);
|
const { data: detail, isLoading: loadingDetail } = useClientDetail(summary?.idCliente);
|
||||||
const { data: orders = [], isLoading: loadingOrders } = useClientOrders(summary?.idCliente);
|
const { data: orders = [], isLoading: loadingOrders } = useClientOrders(summary?.idCliente);
|
||||||
|
|
||||||
@@ -439,7 +448,7 @@ function CustomerDetailsDrawer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Modal
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<Text strong style={{ fontSize: 16 }}>
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
@@ -448,17 +457,40 @@ function CustomerDetailsDrawer({
|
|||||||
<CustomerStatusBadge status={summary.activityStatus} />
|
<CustomerStatusBadge status={summary.activityStatus} />
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
open
|
open={!!summary}
|
||||||
onClose={onClose}
|
onCancel={onClose}
|
||||||
width={520}
|
centered
|
||||||
placement="right"
|
width={isMobile ? '95vw' : 860}
|
||||||
styles={{ body: { padding: '16px 24px' } }}
|
destroyOnHidden
|
||||||
|
styles={{
|
||||||
|
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
|
||||||
|
}}
|
||||||
footer={
|
footer={
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button onClick={onClose}>Fechar</Button>
|
<Button onClick={onClose}>Fechar</Button>
|
||||||
|
<Button
|
||||||
|
icon={<WhatsAppOutlined />}
|
||||||
|
style={{
|
||||||
|
background: '#25D366',
|
||||||
|
borderColor: '#25D366',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
onClick={() => window.open(`https://wa.me/55${phone.replace(/\D/g, '')}`, '_blank')}
|
||||||
|
>
|
||||||
|
WhatsApp
|
||||||
|
</Button>
|
||||||
<Button icon={<BarChartOutlined />} onClick={onAnalyze}>
|
<Button icon={<BarChartOutlined />} onClick={onAnalyze}>
|
||||||
Analisar
|
Analisar
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
void navigate({ to: '/clientes/$id', params: { id: String(summary.idCliente) } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Abrir ficha
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
@@ -475,12 +507,14 @@ function CustomerDetailsDrawer({
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||||
{/* Identificação */}
|
{/* Identificação */}
|
||||||
<Card
|
<Card
|
||||||
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
||||||
styles={{ body: { padding: '14px 16px' } }}
|
styles={{ body: { padding: '14px 16px' } }}
|
||||||
>
|
>
|
||||||
|
<Row gutter={[16, 8]}>
|
||||||
|
<Col span={16}>
|
||||||
<Text strong style={{ fontSize: 18, color: '#1F2937', display: 'block' }}>
|
<Text strong style={{ fontSize: 18, color: '#1F2937', display: 'block' }}>
|
||||||
{summary.nome}
|
{summary.nome}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -494,10 +528,16 @@ function CustomerDetailsDrawer({
|
|||||||
{summary.cgcpf}
|
{summary.cgcpf}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col span={8} style={{ textAlign: 'right' }}>
|
||||||
|
<CustomerStatusBadge status={summary.activityStatus} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Dados cadastrais */}
|
{/* Dados cadastrais + comerciais em 2 colunas */}
|
||||||
<div>
|
<Row gutter={[24, 16]}>
|
||||||
|
<Col span={12}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -511,48 +551,45 @@ function CustomerDetailsDrawer({
|
|||||||
>
|
>
|
||||||
Dados Cadastrais
|
Dados Cadastrais
|
||||||
</Text>
|
</Text>
|
||||||
<Row gutter={[12, 12]}>
|
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
|
||||||
<Col span={12}>
|
<div>
|
||||||
<span style={label}>Telefone</span>
|
<span style={label}>Telefone</span>
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<PhoneOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
<PhoneOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
||||||
<Text style={{ fontSize: 13 }}>{phone}</Text>
|
<Text style={{ fontSize: 13 }}>{phone}</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</div>
|
||||||
<Col span={12}>
|
<div>
|
||||||
<span style={label}>E-mail</span>
|
<span style={label}>E-mail</span>
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<MailOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
<MailOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
||||||
<Text style={{ fontSize: 13, wordBreak: 'break-all' }}>{summary.email ?? '—'}</Text>
|
<Text style={{ fontSize: 13, wordBreak: 'break-all' }}>
|
||||||
|
{summary.email ?? '—'}
|
||||||
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</div>
|
||||||
{loadingDetail ? (
|
{loadingDetail ? (
|
||||||
<Col span={24}>
|
|
||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
</Col>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{endereco !== '—' && (
|
{endereco !== '—' && (
|
||||||
<Col span={24}>
|
<div>
|
||||||
<span style={label}>Endereço</span>
|
<span style={label}>Endereço</span>
|
||||||
<Text style={{ fontSize: 13 }}>{endereco}</Text>
|
<Text style={{ fontSize: 13 }}>{endereco}</Text>
|
||||||
</Col>
|
</div>
|
||||||
)}
|
)}
|
||||||
{d?.inscricaoEstadual && (
|
{d?.inscricaoEstadual && (
|
||||||
<Col span={12}>
|
<div>
|
||||||
<span style={label}>Insc. Estadual</span>
|
<span style={label}>Insc. Estadual</span>
|
||||||
<Text style={{ fontSize: 13 }}>{d.inscricaoEstadual}</Text>
|
<Text style={{ fontSize: 13 }}>{d.inscricaoEstadual}</Text>
|
||||||
</Col>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Space>
|
||||||
</div>
|
</Col>
|
||||||
|
|
||||||
<Divider style={{ margin: '4px 0' }} />
|
<Col span={12}>
|
||||||
|
|
||||||
{/* Dados comerciais */}
|
|
||||||
<div>
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -566,16 +603,16 @@ function CustomerDetailsDrawer({
|
|||||||
>
|
>
|
||||||
Dados Comerciais
|
Dados Comerciais
|
||||||
</Text>
|
</Text>
|
||||||
<Row gutter={[12, 12]}>
|
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
|
||||||
{limiteFormatado !== '—' && (
|
{limiteFormatado !== '—' && (
|
||||||
<Col span={12}>
|
<div>
|
||||||
<span style={label}>Limite de Crédito</span>
|
<span style={label}>Limite de Crédito</span>
|
||||||
<Text strong style={{ fontSize: 13, color: '#003B8E' }}>
|
<Text strong style={{ fontSize: 13, color: '#003B8E' }}>
|
||||||
{limiteFormatado}
|
{limiteFormatado}
|
||||||
</Text>
|
</Text>
|
||||||
</Col>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Col span={12}>
|
<div>
|
||||||
<span style={label}>Representante</span>
|
<span style={label}>Representante</span>
|
||||||
<Text style={{ fontSize: 13 }}>
|
<Text style={{ fontSize: 13 }}>
|
||||||
{summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`}
|
{summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`}
|
||||||
@@ -585,9 +622,9 @@ function CustomerDetailsDrawer({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Col>
|
</div>
|
||||||
{summary.dtUltimaCompra && (
|
{summary.dtUltimaCompra && (
|
||||||
<Col span={12}>
|
<div>
|
||||||
<span style={label}>Última Compra</span>
|
<span style={label}>Última Compra</span>
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<CalendarOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
<CalendarOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
|
||||||
@@ -600,10 +637,11 @@ function CustomerDetailsDrawer({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
{/* Últimos pedidos */}
|
{/* Últimos pedidos */}
|
||||||
{(loadingOrders || orders.length > 0) && (
|
{(loadingOrders || orders.length > 0) && (
|
||||||
@@ -626,7 +664,7 @@ function CustomerDetailsDrawer({
|
|||||||
{loadingOrders ? (
|
{loadingOrders ? (
|
||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
) : (
|
) : (
|
||||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||||
{orders.slice(0, 5).map((p) => (
|
{orders.slice(0, 5).map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -698,35 +736,22 @@ function CustomerDetailsDrawer({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
|
||||||
block
|
|
||||||
icon={<WhatsAppOutlined />}
|
|
||||||
style={{
|
|
||||||
borderRadius: 8,
|
|
||||||
background: '#25D366',
|
|
||||||
borderColor: '#25D366',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
onClick={() => window.open(`https://wa.me/55${phone.replace(/\D/g, '')}`, '_blank')}
|
|
||||||
>
|
|
||||||
Enviar WhatsApp
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
</Drawer>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── CustomerAnalysisDrawer ───────────────────────────────────────────────────
|
// ─── CustomerAnalysisModal ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function CustomerAnalysisDrawer({
|
function CustomerAnalysisModal({
|
||||||
summary,
|
summary,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
summary: ClientSummary | null;
|
summary: ClientSummary | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const { data: detail } = useClientDetail(summary?.idCliente);
|
const { data: detail } = useClientDetail(summary?.idCliente);
|
||||||
|
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
@@ -747,25 +772,55 @@ function CustomerAnalysisDrawer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Modal
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<BarChartOutlined />
|
<BarChartOutlined />
|
||||||
<Text strong>Análise Comercial</Text>
|
<Text strong>Análise Comercial — {summary.nome}</Text>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
open
|
open={!!summary}
|
||||||
onClose={onClose}
|
onCancel={onClose}
|
||||||
width={480}
|
centered
|
||||||
placement="right"
|
width={isMobile ? '95vw' : 720}
|
||||||
styles={{ body: { padding: '16px 24px' } }}
|
destroyOnHidden
|
||||||
|
styles={{ body: { padding: '20px 24px' } }}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<WhatsAppOutlined />}
|
||||||
|
style={{
|
||||||
|
background: '#25D366',
|
||||||
|
borderColor: '#25D366',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`,
|
||||||
|
'_blank',
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
WhatsApp
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<MailOutlined />}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
onClick={() => window.open(`mailto:${summary.email ?? ''}`, '_blank')}
|
||||||
|
>
|
||||||
|
E-mail
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose} style={{ borderRadius: 8 }}>
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||||
<div>
|
<div>
|
||||||
<Text strong style={{ fontSize: 17, color: '#1F2937', display: 'block' }}>
|
<Space size={8}>
|
||||||
{summary.nome}
|
|
||||||
</Text>
|
|
||||||
<Space size={8} style={{ marginTop: 4 }}>
|
|
||||||
<CustomerStatusBadge status={summary.activityStatus} />
|
<CustomerStatusBadge status={summary.activityStatus} />
|
||||||
{summary.cgcpf && (
|
{summary.cgcpf && (
|
||||||
<Text style={{ fontSize: 12, color: '#64748B' }}>{summary.cgcpf}</Text>
|
<Text style={{ fontSize: 12, color: '#64748B' }}>{summary.cgcpf}</Text>
|
||||||
@@ -867,39 +922,8 @@ function CustomerAnalysisDrawer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
icon={<WhatsAppOutlined />}
|
|
||||||
style={{
|
|
||||||
background: '#25D366',
|
|
||||||
borderColor: '#25D366',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 600,
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
onClick={() =>
|
|
||||||
window.open(
|
|
||||||
`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`,
|
|
||||||
'_blank',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
WhatsApp
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
icon={<MailOutlined />}
|
|
||||||
style={{ borderRadius: 8 }}
|
|
||||||
onClick={() => window.open(`mailto:${summary.email ?? ''}`, '_blank')}
|
|
||||||
>
|
|
||||||
E-mail
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onClose} style={{ borderRadius: 8 }}>
|
|
||||||
Fechar
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Modal>
|
||||||
</Drawer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,6 +1066,7 @@ export function ClientsPage() {
|
|||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const { message: msg } = App.useApp();
|
const { message: msg } = App.useApp();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const stats = usePortfolioStats();
|
const stats = usePortfolioStats();
|
||||||
|
|
||||||
@@ -1062,7 +1087,7 @@ export function ClientsPage() {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = data?.data ?? [];
|
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const r = [...rows];
|
const r = [...rows];
|
||||||
@@ -1113,7 +1138,9 @@ export function ClientsPage() {
|
|||||||
display: 'block',
|
display: 'block',
|
||||||
lineHeight: 1.3,
|
lineHeight: 1.3,
|
||||||
}}
|
}}
|
||||||
onClick={() => setDetailSummary(c)}
|
onClick={() =>
|
||||||
|
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{c.nome}
|
{c.nome}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1291,7 +1318,7 @@ export function ClientsPage() {
|
|||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
size="large"
|
size="large"
|
||||||
style={{ borderRadius: 8, fontWeight: 600 }}
|
style={{ borderRadius: 8, fontWeight: 600 }}
|
||||||
onClick={() => void msg.info('Cadastro de clientes em breve.')}
|
onClick={() => void navigate({ to: '/clientes/novo' })}
|
||||||
>
|
>
|
||||||
Cadastrar Cliente
|
Cadastrar Cliente
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1318,7 +1345,7 @@ export function ClientsPage() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
style={{ borderRadius: 8, fontWeight: 600 }}
|
style={{ borderRadius: 8, fontWeight: 600 }}
|
||||||
onClick={() => void msg.info('Cadastro de clientes em breve.')}
|
onClick={() => void navigate({ to: '/clientes/novo' })}
|
||||||
>
|
>
|
||||||
Cadastrar Cliente
|
Cadastrar Cliente
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1436,14 +1463,19 @@ export function ClientsPage() {
|
|||||||
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
|
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<>
|
<>
|
||||||
<CustomerPortfolioCard stats={stats} />
|
<CustomerPortfolioCard
|
||||||
|
stats={stats}
|
||||||
|
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
|
||||||
|
/>
|
||||||
<div style={{ marginBottom: 16 }} />
|
<div style={{ marginBottom: 16 }} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Área principal ────────────────────────────────────────────── */}
|
{/* ── Área principal ────────────────────────────────────────────── */}
|
||||||
<Row gutter={[20, 20]}>
|
<Row gutter={[20, 20]}>
|
||||||
<Col xs={24} lg={17}>
|
{/* minWidth: 0 — Col é flex item (min-width auto). Sem isto a tabela com
|
||||||
|
scroll x estoura a coluna e engole o card vizinho. */}
|
||||||
|
<Col xs={24} lg={17} style={{ minWidth: 0 }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: 64 }}>
|
<div style={{ textAlign: 'center', padding: 64 }}>
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
@@ -1519,14 +1551,17 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Col lg={7}>
|
<Col lg={7}>
|
||||||
<CustomerPortfolioCard stats={stats} />
|
<CustomerPortfolioCard
|
||||||
|
stats={stats}
|
||||||
|
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* ── Drawers ───────────────────────────────────────────────────── */}
|
{/* ── Modais ────────────────────────────────────────────────────── */}
|
||||||
{detailSummary && (
|
{detailSummary && (
|
||||||
<CustomerDetailsDrawer
|
<CustomerDetailsModal
|
||||||
summary={detailSummary}
|
summary={detailSummary}
|
||||||
onClose={() => setDetailSummary(null)}
|
onClose={() => setDetailSummary(null)}
|
||||||
onAnalyze={() => {
|
onAnalyze={() => {
|
||||||
@@ -1536,10 +1571,7 @@ export function ClientsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{analysisSummary && (
|
{analysisSummary && (
|
||||||
<CustomerAnalysisDrawer
|
<CustomerAnalysisModal summary={analysisSummary} onClose={() => setAnalysisSummary(null)} />
|
||||||
summary={analysisSummary}
|
|
||||||
onClose={() => setAnalysisSummary(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* FAB mobile */}
|
{/* FAB mobile */}
|
||||||
@@ -1549,7 +1581,7 @@ export function ClientsPage() {
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => void msg.info('Cadastro de clientes em breve.')}
|
onClick={() => void navigate({ to: '/clientes/novo' })}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 24,
|
bottom: 24,
|
||||||
|
|||||||
552
apps/web/src/cockpits/rep/FunilPage.tsx
Normal file
552
apps/web/src/cockpits/rep/FunilPage.tsx
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Collapse,
|
||||||
|
Flex,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ArrowRightOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type {
|
||||||
|
Oportunidade,
|
||||||
|
EtapaFunil,
|
||||||
|
CreateOportunidadeDto,
|
||||||
|
UpdateOportunidadeDto,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { ETAPAS_FUNIL } from '@sar/api-interface';
|
||||||
|
import {
|
||||||
|
useFunil,
|
||||||
|
useCreateOportunidade,
|
||||||
|
useUpdateOportunidade,
|
||||||
|
useDeleteOportunidade,
|
||||||
|
} from '../../lib/queries/funil';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const ETAPA_CONFIG: Record<EtapaFunil, { label: string; cor: string; corBg: string }> = {
|
||||||
|
lead: { label: 'Lead', cor: '#64748b', corBg: '#F8FAFC' },
|
||||||
|
proposta: { label: 'Proposta', cor: '#2563EB', corBg: '#EFF6FF' },
|
||||||
|
negociacao: { label: 'Negociação', cor: '#D97706', corBg: '#FFFBEB' },
|
||||||
|
ganho: { label: 'Ganho', cor: '#16A34A', corBg: '#F0FDF4' },
|
||||||
|
perdido: { label: 'Perdido', cor: '#DC2626', corBg: '#FEF2F2' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROXIMA: Partial<Record<EtapaFunil, EtapaFunil>> = {
|
||||||
|
lead: 'proposta',
|
||||||
|
proposta: 'negociacao',
|
||||||
|
negociacao: 'ganho',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANTERIOR: Partial<Record<EtapaFunil, EtapaFunil>> = {
|
||||||
|
proposta: 'lead',
|
||||||
|
negociacao: 'proposta',
|
||||||
|
ganho: 'negociacao',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(v: string | null | undefined): string {
|
||||||
|
if (!v) return '';
|
||||||
|
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function diasDesde(iso: string): number {
|
||||||
|
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nomePrincipal(op: Oportunidade): string {
|
||||||
|
return op.nomeCliente ?? op.nomeProspect ?? op.empresaProspect ?? `Oport. #${op.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OportCard({
|
||||||
|
op,
|
||||||
|
onEdit,
|
||||||
|
onMover,
|
||||||
|
onDeletar,
|
||||||
|
}: {
|
||||||
|
op: Oportunidade;
|
||||||
|
onEdit: () => void;
|
||||||
|
onMover: (etapa: EtapaFunil) => void;
|
||||||
|
onDeletar: () => void;
|
||||||
|
}) {
|
||||||
|
const dias = diasDesde(op.updatedAt);
|
||||||
|
const prox = PROXIMA[op.etapa as EtapaFunil];
|
||||||
|
const ant = ANTERIOR[op.etapa as EtapaFunil];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 10, borderRadius: 8, border: '1px solid #E2E8F0' }}
|
||||||
|
styles={{ body: { padding: '10px 12px' } }}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="flex-start" gap={8}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1e293b', display: 'block' }} ellipsis>
|
||||||
|
{nomePrincipal(op)}
|
||||||
|
</Text>
|
||||||
|
{op.titulo && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }} ellipsis>
|
||||||
|
{op.titulo}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Space size={2}>
|
||||||
|
<Tooltip title="Editar">
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Excluir">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Excluir oportunidade?',
|
||||||
|
content: 'Esta ação não pode ser desfeita.',
|
||||||
|
okText: 'Excluir',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: onDeletar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginTop: 6 }}>
|
||||||
|
<Text style={{ fontSize: 12, color: '#003B8E', fontWeight: 600 }}>
|
||||||
|
{op.valorEstimado ? fmt(op.valorEstimado) : '—'}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{dias === 0 ? 'hoje' : `${dias}d`}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex gap={4} style={{ marginTop: 8 }}>
|
||||||
|
{ant && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{ fontSize: 11, padding: '0 6px', color: '#64748b', borderColor: '#CBD5E1' }}
|
||||||
|
onClick={() => onMover(ant)}
|
||||||
|
>
|
||||||
|
← {ETAPA_CONFIG[ant].label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{op.etapa !== 'perdido' && op.etapa !== 'ganho' && (
|
||||||
|
<Tooltip title="Marcar como perdido">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
ghost
|
||||||
|
style={{ fontSize: 11, padding: '0 6px' }}
|
||||||
|
onClick={() => onMover('perdido')}
|
||||||
|
>
|
||||||
|
Perdido
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{prox && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '0 6px',
|
||||||
|
background: ETAPA_CONFIG[prox].cor,
|
||||||
|
borderColor: ETAPA_CONFIG[prox].cor,
|
||||||
|
}}
|
||||||
|
onClick={() => onMover(prox)}
|
||||||
|
>
|
||||||
|
{ETAPA_CONFIG[prox].label} <ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{op.etapa === 'ganho' && (
|
||||||
|
<Tag color="green" style={{ fontSize: 11, margin: 0 }}>
|
||||||
|
<TrophyOutlined /> Ganho
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal criar/editar ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
titulo: string;
|
||||||
|
etapa: EtapaFunil;
|
||||||
|
nomeProspect?: string;
|
||||||
|
empresaProspect?: string;
|
||||||
|
valorEstimado?: number;
|
||||||
|
observacoes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function OportModal({
|
||||||
|
open,
|
||||||
|
initial,
|
||||||
|
etapaInicial,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
initial?: Oportunidade | null;
|
||||||
|
etapaInicial: EtapaFunil;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (dto: CreateOportunidadeDto | UpdateOportunidadeDto) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
titulo: values.titulo,
|
||||||
|
etapa: values.etapa,
|
||||||
|
nomeProspect: values.nomeProspect ?? null,
|
||||||
|
empresaProspect: values.empresaProspect ?? null,
|
||||||
|
valorEstimado: values.valorEstimado ?? null,
|
||||||
|
observacoes: values.observacoes ?? null,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={initial ? 'Editar Oportunidade' : 'Nova Oportunidade'}
|
||||||
|
width={520}
|
||||||
|
centered
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={handleOk}
|
||||||
|
okText={initial ? 'Salvar' : 'Criar'}
|
||||||
|
confirmLoading={saving}
|
||||||
|
afterOpenChange={(vis) => {
|
||||||
|
if (vis) {
|
||||||
|
form.setFieldsValue(
|
||||||
|
initial
|
||||||
|
? {
|
||||||
|
titulo: initial.titulo,
|
||||||
|
etapa: initial.etapa as EtapaFunil,
|
||||||
|
nomeProspect: initial.nomeProspect ?? undefined,
|
||||||
|
empresaProspect: initial.empresaProspect ?? undefined,
|
||||||
|
valorEstimado: initial.valorEstimado ? Number(initial.valorEstimado) : undefined,
|
||||||
|
observacoes: initial.observacoes ?? undefined,
|
||||||
|
}
|
||||||
|
: { etapa: etapaInicial },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="titulo"
|
||||||
|
label="Título / produto de interesse"
|
||||||
|
rules={[{ required: true, message: 'Informe o título' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Ex: Reposição de estoque Q3" />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="nomeProspect" label="Nome do contato">
|
||||||
|
<Input placeholder="João Silva" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="empresaProspect" label="Empresa">
|
||||||
|
<Input placeholder="Empresa XYZ" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="valorEstimado" label="Valor estimado (R$)">
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
formatter={(v) => String(v).replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
|
||||||
|
parser={(v) => Number((v ?? '').replace(/\./g, '').replace(',', '.'))}
|
||||||
|
placeholder="0,00"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="etapa" label="Estágio">
|
||||||
|
<Select
|
||||||
|
options={ETAPAS_FUNIL.map((e) => ({
|
||||||
|
value: e,
|
||||||
|
label: ETAPA_CONFIG[e].label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="observacoes" label="Observações">
|
||||||
|
<Input.TextArea rows={3} placeholder="Anotações sobre o lead..." />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Coluna kanban ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function KanbanColuna({
|
||||||
|
etapa,
|
||||||
|
ops,
|
||||||
|
onEdit,
|
||||||
|
onMover,
|
||||||
|
onDeletar,
|
||||||
|
onNovo,
|
||||||
|
}: {
|
||||||
|
etapa: EtapaFunil;
|
||||||
|
ops: Oportunidade[];
|
||||||
|
onEdit: (op: Oportunidade) => void;
|
||||||
|
onMover: (id: number, etapa: EtapaFunil) => void;
|
||||||
|
onDeletar: (id: number) => void;
|
||||||
|
onNovo: (etapa: EtapaFunil) => void;
|
||||||
|
}) {
|
||||||
|
const cfg = ETAPA_CONFIG[etapa];
|
||||||
|
const total = ops.reduce((s, o) => s + (o.valorEstimado ? Number(o.valorEstimado) : 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: 240,
|
||||||
|
flex: 1,
|
||||||
|
background: cfg.corBg,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${cfg.cor}33`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px 8px',
|
||||||
|
borderBottom: `2px solid ${cfg.cor}`,
|
||||||
|
borderRadius: '10px 10px 0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Space size={6}>
|
||||||
|
<Text strong style={{ color: cfg.cor, fontSize: 13 }}>
|
||||||
|
{cfg.label}
|
||||||
|
</Text>
|
||||||
|
<Badge count={ops.length} color={cfg.cor} style={{ fontSize: 11 }} />
|
||||||
|
</Space>
|
||||||
|
{total > 0 && (
|
||||||
|
<Text style={{ fontSize: 11, color: cfg.cor, fontWeight: 600 }}>
|
||||||
|
{total.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 10px 4px',
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
maxHeight: 'calc(100vh - 260px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ops.map((op) => (
|
||||||
|
<OportCard
|
||||||
|
key={op.id}
|
||||||
|
op={op}
|
||||||
|
onEdit={() => onEdit(op)}
|
||||||
|
onMover={(e) => onMover(op.id, e)}
|
||||||
|
onDeletar={() => onDeletar(op.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '4px 10px 10px' }}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
style={{ borderColor: cfg.cor, color: cfg.cor, fontSize: 12 }}
|
||||||
|
onClick={() => onNovo(etapa)}
|
||||||
|
>
|
||||||
|
Adicionar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FunilPage ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function FunilPage() {
|
||||||
|
const { data, isLoading, isError, error } = useFunil();
|
||||||
|
const criar = useCreateOportunidade();
|
||||||
|
const atualizar = useUpdateOportunidade();
|
||||||
|
const deletar = useDeleteOportunidade();
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editOp, setEditOp] = useState<Oportunidade | null>(null);
|
||||||
|
const [etapaModal, setEtapaModal] = useState<EtapaFunil>('lead');
|
||||||
|
|
||||||
|
const abrirNovo = (etapa: EtapaFunil) => {
|
||||||
|
setEditOp(null);
|
||||||
|
setEtapaModal(etapa);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const abrirEditar = (op: Oportunidade) => {
|
||||||
|
setEditOp(op);
|
||||||
|
setEtapaModal(op.etapa as EtapaFunil);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (dto: CreateOportunidadeDto | UpdateOportunidadeDto) => {
|
||||||
|
if (editOp) {
|
||||||
|
await atualizar.mutateAsync({ id: editOp.id, dto });
|
||||||
|
} else {
|
||||||
|
await criar.mutateAsync(dto as CreateOportunidadeDto);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMover = (id: number, etapa: EtapaFunil) => {
|
||||||
|
atualizar.mutate({ id, dto: { etapa } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletar = (id: number) => {
|
||||||
|
deletar.mutate(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const etapasPrincipais: EtapaFunil[] = ['lead', 'proposta', 'negociacao', 'ganho'];
|
||||||
|
const perdidos = data?.perdido ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 'calc(100vh - var(--layout-topbar-height) - 48px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex align="center" justify="space-between" style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
Funil de Vendas
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Acompanhe seus leads até o fechamento do pedido
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => abrirNovo('lead')}
|
||||||
|
style={{ background: '#003B8E' }}
|
||||||
|
>
|
||||||
|
Novo Lead
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Erro ao carregar funil"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
style={{ marginBottom: 16, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flex: 1, overflowX: 'auto', paddingBottom: 8 }}>
|
||||||
|
{etapasPrincipais.map((etapa) => (
|
||||||
|
<KanbanColuna
|
||||||
|
key={etapa}
|
||||||
|
etapa={etapa}
|
||||||
|
ops={data?.[etapa] ?? []}
|
||||||
|
onEdit={abrirEditar}
|
||||||
|
onMover={handleMover}
|
||||||
|
onDeletar={handleDeletar}
|
||||||
|
onNovo={abrirNovo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{perdidos.length > 0 && (
|
||||||
|
<div style={{ marginTop: 12, flexShrink: 0 }}>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'perdidos',
|
||||||
|
label: (
|
||||||
|
<Text style={{ color: '#DC2626', fontSize: 13 }}>
|
||||||
|
Perdidos ({perdidos.length})
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<Row gutter={[10, 10]}>
|
||||||
|
{perdidos.map((op) => (
|
||||||
|
<Col key={op.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<OportCard
|
||||||
|
op={op}
|
||||||
|
onEdit={() => abrirEditar(op)}
|
||||||
|
onMover={(e) => handleMover(op.id, e)}
|
||||||
|
onDeletar={() => handleDeletar(op.id)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OportModal
|
||||||
|
open={modalOpen}
|
||||||
|
initial={editOp}
|
||||||
|
etapaInicial={etapaModal}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
435
apps/web/src/cockpits/rep/NewClientPage.tsx
Normal file
435
apps/web/src/cockpits/rep/NewClientPage.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Row,
|
||||||
|
Segmented,
|
||||||
|
Select,
|
||||||
|
Spin,
|
||||||
|
Switch,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
BankOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
ShopOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import type { CreateClientNovo } from '@sar/api-interface';
|
||||||
|
import { useMunicipios, usePautas, useFormasPagamento } from '../../lib/queries/catalog';
|
||||||
|
import { useCreateClientNovo } from '../../lib/queries/clients';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
type Pessoa = 'PJ' | 'PF';
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottom: '1px solid #EBF0F5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#003B8E', fontSize: 16 }}>{icon}</span>
|
||||||
|
<Text strong style={{ fontSize: 14, color: '#003B8E' }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewClientPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [pessoa, setPessoa] = useState<Pessoa>('PJ');
|
||||||
|
const [municipioSearch, setMunicipioSearch] = useState('');
|
||||||
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: municipios = [], isFetching: loadingMun } = useMunicipios(
|
||||||
|
municipioSearch || undefined,
|
||||||
|
);
|
||||||
|
const { data: pautas = [] } = usePautas();
|
||||||
|
const { data: formas = [] } = useFormasPagamento();
|
||||||
|
const createMutation = useCreateClientNovo();
|
||||||
|
|
||||||
|
async function handleCepBlur(cep: string) {
|
||||||
|
const digits = cep.replace(/\D/g, '');
|
||||||
|
if (digits.length !== 8) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://viacep.com.br/ws/${digits}/json/`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
erro?: boolean;
|
||||||
|
logradouro?: string;
|
||||||
|
bairro?: string;
|
||||||
|
localidade?: string;
|
||||||
|
uf?: string;
|
||||||
|
};
|
||||||
|
if (data.erro) return;
|
||||||
|
if (data.logradouro) form.setFieldValue('endereco', data.logradouro);
|
||||||
|
if (data.bairro) form.setFieldValue('bairro', data.bairro);
|
||||||
|
if (data.localidade && data.uf) {
|
||||||
|
setMunicipioSearch(data.localidade);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silencioso — CEP inválido não bloqueia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFinish(values: Record<string, unknown>) {
|
||||||
|
setSyncError(null);
|
||||||
|
|
||||||
|
const payload: CreateClientNovo = {
|
||||||
|
pesso: pessoa === 'PJ' ? 0 : 1,
|
||||||
|
consfinal: values.consfinal ? 1 : 0,
|
||||||
|
nome: String(values.nome ?? '').slice(0, 40),
|
||||||
|
razao: String(values.razao ?? values.nome ?? '').slice(0, 65),
|
||||||
|
cgcpf: values.cgcpf ? String(values.cgcpf) : undefined,
|
||||||
|
sufCgcpf: '',
|
||||||
|
inscr: values.inscr ? String(values.inscr) : '',
|
||||||
|
indicadorIe: values.indicadorIe as 1 | 2 | 9 | undefined,
|
||||||
|
endereco: String(values.endereco ?? '').slice(0, 60),
|
||||||
|
numEndereco: String(values.numEndereco ?? '').slice(0, 10),
|
||||||
|
bairro: String(values.bairro ?? '').slice(0, 60),
|
||||||
|
idMunicipio: Number(values.idMunicipio),
|
||||||
|
cep: String(values.cep ?? '')
|
||||||
|
.replace(/\D/g, '')
|
||||||
|
.slice(0, 9),
|
||||||
|
ddd: String(values.ddd ?? '').slice(0, 4),
|
||||||
|
telefone: String(values.telefone ?? '').slice(0, 35),
|
||||||
|
email: String(values.email ?? '').slice(0, 80),
|
||||||
|
limiteCredito: Number(values.limiteCredito ?? 0),
|
||||||
|
codFormapag: values.codFormapag ? Number(values.codFormapag) : undefined,
|
||||||
|
codPauta: values.codPauta ? Number(values.codPauta) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||||
|
try {
|
||||||
|
result = await createMutation.mutateAsync(payload);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.sincronizado && result.erroSync) {
|
||||||
|
setSyncError(result.erroSync);
|
||||||
|
void message.warning('Cliente cadastrado, mas houve erro na sincronização com o ERP.');
|
||||||
|
} else {
|
||||||
|
void message.success(
|
||||||
|
`Cliente cadastrado com sucesso! Código ERP: ${result.idCorrentErp ?? '—'}`,
|
||||||
|
);
|
||||||
|
void navigate({ to: '/clientes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPJ = pessoa === 'PJ';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 860, margin: '0 auto', padding: '24px 16px' }}>
|
||||||
|
{/* Cabeçalho */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => void navigate({ to: '/clientes' })}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
Cadastrar Cliente
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Após salvar, o cliente é criado automaticamente no ERP.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seletor PJ / PF */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Segmented
|
||||||
|
size="large"
|
||||||
|
value={pessoa}
|
||||||
|
onChange={(v) => {
|
||||||
|
setPessoa(v as Pessoa);
|
||||||
|
form.resetFields(['cgcpf', 'inscr', 'indicadorIe', 'consfinal', 'razao']);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'PJ', icon: <BankOutlined />, label: 'CNPJ — Pessoa Jurídica' },
|
||||||
|
{ value: 'PF', icon: <UserOutlined />, label: 'CPF — Pessoa Física' },
|
||||||
|
]}
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(v) => void onFinish(v as Record<string, unknown>)}
|
||||||
|
requiredMark="optional"
|
||||||
|
initialValues={{ consfinal: false, limiteCredito: 0 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 12, border: '1px solid #EBF0F5', marginBottom: 16 }}
|
||||||
|
styles={{ body: { padding: '20px 24px' } }}
|
||||||
|
>
|
||||||
|
{/* ── Identificação ─────────────────────────────────────────── */}
|
||||||
|
<Section icon={isPJ ? <ShopOutlined /> : <UserOutlined />} title="Identificação">
|
||||||
|
<Row gutter={[16, 0]}>
|
||||||
|
{isPJ ? (
|
||||||
|
<>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="razao"
|
||||||
|
label="Razão Social"
|
||||||
|
rules={[{ required: true, message: 'Informe a razão social' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Razão Social Ltda" maxLength={65} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="nome"
|
||||||
|
label="Nome Fantasia"
|
||||||
|
rules={[{ required: true, message: 'Informe o nome fantasia' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Nome fantasia" maxLength={40} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="cgcpf" label="CNPJ">
|
||||||
|
<Input placeholder="00.000.000/0000-00" maxLength={18} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="inscr" label="Inscrição Estadual">
|
||||||
|
<Input placeholder="000.000.000" maxLength={18} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="indicadorIe" label="Indicador IE">
|
||||||
|
<Select placeholder="Selecione" allowClear>
|
||||||
|
<Select.Option value={1}>Contribuinte</Select.Option>
|
||||||
|
<Select.Option value={2}>Isento</Select.Option>
|
||||||
|
<Select.Option value={9}>Não contribuinte</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="consfinal" label="Consumidor Final" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Col xs={24} md={16}>
|
||||||
|
<Form.Item
|
||||||
|
name="nome"
|
||||||
|
label="Nome Completo"
|
||||||
|
rules={[{ required: true, message: 'Informe o nome' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Nome completo" maxLength={40} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="cgcpf" label="CPF">
|
||||||
|
<Input placeholder="000.000.000-00" maxLength={14} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Endereço ──────────────────────────────────────────────── */}
|
||||||
|
<Section icon={<EnvironmentOutlined />} title="Endereço">
|
||||||
|
<Row gutter={[16, 0]}>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Item name="cep" label="CEP">
|
||||||
|
<Input
|
||||||
|
placeholder="00000-000"
|
||||||
|
maxLength={9}
|
||||||
|
onBlur={(e) => void handleCepBlur(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={14}>
|
||||||
|
<Form.Item name="endereco" label="Endereço">
|
||||||
|
<Input placeholder="Rua / Av." maxLength={60} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={4}>
|
||||||
|
<Form.Item name="numEndereco" label="Número">
|
||||||
|
<Input placeholder="123" maxLength={10} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={10}>
|
||||||
|
<Form.Item name="bairro" label="Bairro">
|
||||||
|
<Input placeholder="Bairro" maxLength={60} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={14}>
|
||||||
|
<Form.Item
|
||||||
|
name="idMunicipio"
|
||||||
|
label="Município"
|
||||||
|
rules={[{ required: true, message: 'Selecione o município' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="Digite para buscar..."
|
||||||
|
filterOption={false}
|
||||||
|
onSearch={setMunicipioSearch}
|
||||||
|
notFoundContent={loadingMun ? <Spin size="small" /> : 'Nenhum resultado'}
|
||||||
|
optionLabelProp="label"
|
||||||
|
>
|
||||||
|
{municipios.map((m) => (
|
||||||
|
<Select.Option
|
||||||
|
key={m.idMunicipio}
|
||||||
|
value={m.idMunicipio}
|
||||||
|
label={`${m.nome} — ${m.uf}`}
|
||||||
|
>
|
||||||
|
{m.nome}{' '}
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{m.uf}
|
||||||
|
</Text>
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Contato ───────────────────────────────────────────────── */}
|
||||||
|
<Section icon={<PhoneOutlined />} title="Contato">
|
||||||
|
<Row gutter={[16, 0]}>
|
||||||
|
<Col xs={8} md={4}>
|
||||||
|
<Form.Item name="ddd" label="DDD">
|
||||||
|
<Input placeholder="48" maxLength={4} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={16} md={8}>
|
||||||
|
<Form.Item name="telefone" label="Telefone">
|
||||||
|
<Input placeholder="99999-9999" maxLength={35} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Item name="email" label="E-mail">
|
||||||
|
<Input placeholder="contato@empresa.com.br" maxLength={80} type="email" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── Comercial ─────────────────────────────────────────────── */}
|
||||||
|
<Section icon={<BankOutlined />} title="Comercial">
|
||||||
|
<Row gutter={[16, 0]}>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="codFormapag" label="Forma de Pagamento">
|
||||||
|
<Select placeholder="Selecione" allowClear>
|
||||||
|
{formas.map((f) => (
|
||||||
|
<Select.Option key={f.codigo} value={f.codigo}>
|
||||||
|
{f.descricao}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="codPauta" label="Tabela de Preços">
|
||||||
|
<Select placeholder="Selecione" allowClear>
|
||||||
|
{pautas.map((p) => (
|
||||||
|
<Select.Option key={p.idPauta} value={p.idPauta}>
|
||||||
|
{p.descricao}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item name="limiteCredito" label="Limite de Crédito">
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
formatter={(v) => `R$ ${v ?? 0}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
|
||||||
|
parser={(v) => Number((v ?? '').replace(/R\$\s?\.*/g, '').replace(',', '.'))}
|
||||||
|
placeholder="0,00"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{syncError && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="Falha na sincronização com o ERP"
|
||||||
|
description={syncError}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Rodapé sticky */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'sticky',
|
||||||
|
bottom: 0,
|
||||||
|
background: '#fff',
|
||||||
|
borderTop: '1px solid #EBF0F5',
|
||||||
|
padding: '12px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 12,
|
||||||
|
borderRadius: '0 0 12px 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => void navigate({ to: '/clientes' })}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
style={{ borderRadius: 8, fontWeight: 600, minWidth: 160 }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Cadastrando...' : 'Cadastrar Cliente'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,8 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FilePdfOutlined } from '@ant-design/icons';
|
import { FilePdfOutlined, CustomerServiceOutlined } from '@ant-design/icons';
|
||||||
|
import { AbrirChamadoModal } from './AbrirChamadoModal';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { Link, useParams, useNavigate } from '@tanstack/react-router';
|
import { Link, useParams, useNavigate } from '@tanstack/react-router';
|
||||||
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
|
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
|
||||||
@@ -118,7 +119,7 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
|
|||||||
: SITUA_COLOR[h.situaNova] === 'error'
|
: SITUA_COLOR[h.situaNova] === 'error'
|
||||||
? 'red'
|
? 'red'
|
||||||
: 'blue',
|
: 'blue',
|
||||||
children: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
|
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
|
||||||
{h.situaAnterior != null && (
|
{h.situaAnterior != null && (
|
||||||
@@ -252,8 +253,9 @@ export function OrderDetailPage() {
|
|||||||
const { data: clientOrders } = useClientOrders(order?.idCliente);
|
const { data: clientOrders } = useClientOrders(order?.idCliente);
|
||||||
|
|
||||||
const role = getRoleFromToken();
|
const role = getRoleFromToken();
|
||||||
const canAct = role !== 'rep' && order?.situa === 1;
|
const isErp = order?.fonte === 'erp';
|
||||||
const canTransmit = role === 'rep' && order?.situa === 0;
|
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
|
||||||
|
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
|
||||||
const canShare =
|
const canShare =
|
||||||
role === 'rep' &&
|
role === 'rep' &&
|
||||||
(order?.situa === 2 || order?.situa === 4) &&
|
(order?.situa === 2 || order?.situa === 4) &&
|
||||||
@@ -262,6 +264,7 @@ export function OrderDetailPage() {
|
|||||||
|
|
||||||
const [approveOpen, setApproveOpen] = useState(false);
|
const [approveOpen, setApproveOpen] = useState(false);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [chamadoOpen, setChamadoOpen] = useState(false);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
@@ -382,6 +385,11 @@ export function OrderDetailPage() {
|
|||||||
Compartilhar
|
Compartilhar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{role === 'rep' && !isErp && (order.situa === 2 || order.situa === 4) && (
|
||||||
|
<Button icon={<CustomerServiceOutlined />} onClick={() => setChamadoOpen(true)}>
|
||||||
|
Abrir Chamado
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
{actionError && (
|
{actionError && (
|
||||||
@@ -417,26 +425,36 @@ export function OrderDetailPage() {
|
|||||||
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} — cód. {order.aprovadoPor}
|
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} — cód. {order.aprovadoPor}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
|
{order.formaPagamento && (
|
||||||
|
<Descriptions.Item label="Cond. Pagamento" span={2}>
|
||||||
|
{order.formaPagamento}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
|
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
|
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
|
||||||
<Descriptions.Item label="Total">
|
<Descriptions.Item label="Total" span={isOrcamento ? 1 : 2}>
|
||||||
<Text strong style={{ fontSize: 16 }}>
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
{fmt(order.total)}
|
{fmt(order.total)}
|
||||||
</Text>
|
</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
{order.endEntrega && (
|
||||||
|
<Descriptions.Item label="End. Entrega" span={isOrcamento ? 3 : 2}>
|
||||||
|
{order.endEntrega}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
{order.obs && (
|
{order.obs && (
|
||||||
<Descriptions.Item label="Observações" span={2}>
|
<Descriptions.Item label="Observações" span={isOrcamento ? 3 : 2}>
|
||||||
{order.obs}
|
{order.obs}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
{order.motivoRecusa && (
|
{order.motivoRecusa && (
|
||||||
<Descriptions.Item label="Motivo Recusa" span={2}>
|
<Descriptions.Item label="Motivo Recusa" span={isOrcamento ? 3 : 2}>
|
||||||
<Text type="danger">{order.motivoRecusa}</Text>
|
<Text type="danger">{order.motivoRecusa}</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Divider orientation="left">Itens ({order.itens.length})</Divider>
|
<Divider titlePlacement="left">Itens ({order.itens.length})</Divider>
|
||||||
<Table<PedidoItem>
|
<Table<PedidoItem>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={itemColumns}
|
columns={itemColumns}
|
||||||
@@ -448,7 +466,7 @@ export function OrderDetailPage() {
|
|||||||
|
|
||||||
{clientOrders && clientOrders.length > 0 && (
|
{clientOrders && clientOrders.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider orientation="left">Outros Pedidos do Cliente</Divider>
|
<Divider titlePlacement="left">Outros Pedidos do Cliente</Divider>
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -490,7 +508,7 @@ export function OrderDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider orientation="left">Histórico do Pedido</Divider>
|
<Divider titlePlacement="left">Histórico do Pedido</Divider>
|
||||||
<HistoryTimeline history={order.historico} />
|
<HistoryTimeline history={order.historico} />
|
||||||
|
|
||||||
<ApproveModal
|
<ApproveModal
|
||||||
@@ -506,6 +524,17 @@ export function OrderDetailPage() {
|
|||||||
onCancel={() => setRejectOpen(false)}
|
onCancel={() => setRejectOpen(false)}
|
||||||
loading={rejectMutation.isPending}
|
loading={rejectMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
{order && (
|
||||||
|
<AbrirChamadoModal
|
||||||
|
open={chamadoOpen}
|
||||||
|
onClose={() => setChamadoOpen(false)}
|
||||||
|
idPedido={order.id}
|
||||||
|
numPedSar={parseInt(order.numPedSar.replace(/\D/g, ''), 10) || undefined}
|
||||||
|
nomeCliente={order.razaoCliente ?? order.nomeCliente}
|
||||||
|
idCliente={order.idCliente}
|
||||||
|
itens={order.itens}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,16 +61,6 @@ const label: React.CSSProperties = {
|
|||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Field({ k, v }: { k: string; v: React.ReactNode }) {
|
|
||||||
if (v == null || v === '' || v === '—') return null;
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 5 }}>
|
|
||||||
<span style={label}>{k}</span>
|
|
||||||
<span style={{ fontSize: 11.5, color: INK }}>{v}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrderPrintPage() {
|
export function OrderPrintPage() {
|
||||||
const { id } = useParams({ from: '/pedidos/$id/imprimir' });
|
const { id } = useParams({ from: '/pedidos/$id/imprimir' });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -213,57 +203,94 @@ export function OrderPrintPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Cliente + Representante ─────────────────────────────────────── */}
|
{/* ── Representante + Cliente ─────────────────────────────────────── */}
|
||||||
<div style={{ display: 'flex', gap: 0 }}>
|
|
||||||
<div style={{ flex: 1.4, padding: '16px 28px', borderRight: `1px solid ${LINE}` }}>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
display: 'flex',
|
||||||
fontWeight: 800,
|
borderBottom: `1px solid ${LINE}`,
|
||||||
color: BLUE,
|
background: '#FAFBFD',
|
||||||
letterSpacing: '0.1em',
|
|
||||||
marginBottom: 10,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
CLIENTE
|
{/* Representante */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px 20px 10px 28px',
|
||||||
|
borderRight: `1px solid ${LINE}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 8.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: BLUE,
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Representante
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 2 }}>
|
<div style={{ fontSize: 12, fontWeight: 700, color: INK, lineHeight: 1.3 }}>
|
||||||
|
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: MUTED, marginTop: 2 }}>Cód. {order.codVendedor}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cliente */}
|
||||||
|
<div style={{ flex: 2.2, padding: '10px 28px 10px 20px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 8.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: BLUE,
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cliente
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: INK, lineHeight: 1.3 }}>
|
||||||
{clienteNome}
|
{clienteNome}
|
||||||
</div>
|
</div>
|
||||||
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && (
|
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && (
|
||||||
<div style={{ fontSize: 11, color: MUTED, marginBottom: 8 }}>{tx(client?.nome)}</div>
|
<div style={{ fontSize: 10, color: MUTED, lineHeight: 1.3 }}>{tx(client?.nome)}</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
<Field k={docLabel} v={doc(client?.cgcpf)} />
|
|
||||||
<Field k="Inscr. Estadual" v={tx(client?.inscricaoEstadual)} />
|
|
||||||
<Field k="Endereço" v={enderecoCli} />
|
|
||||||
<Field k="Telefone" v={phone(client?.telefone, client?.ddd)} />
|
|
||||||
<Field k="E-mail" v={tx(client?.email)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, padding: '16px 28px' }}>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '2px 18px',
|
||||||
|
marginTop: 4,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: 800,
|
color: MUTED,
|
||||||
color: BLUE,
|
|
||||||
letterSpacing: '0.1em',
|
|
||||||
marginBottom: 10,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
REPRESENTANTE
|
{doc(client?.cgcpf) !== '—' && (
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: INK }}>{docLabel}:</strong> {doc(client?.cgcpf)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tx(client?.inscricaoEstadual) && (
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: INK }}>IE:</strong> {tx(client?.inscricaoEstadual)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{enderecoCli && <span>{enderecoCli}</span>}
|
||||||
|
{phone(client?.telefone, client?.ddd) !== '—' && (
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: INK }}>Tel:</strong>{' '}
|
||||||
|
{phone(client?.telefone, client?.ddd)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tx(client?.email) && <span>{tx(client?.email)}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 8 }}>
|
|
||||||
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
|
|
||||||
</div>
|
|
||||||
<Field k="Código" v={String(order.codVendedor)} />
|
|
||||||
<Field k="Data do pedido" v={dateBR(order.dtPedido)} />
|
|
||||||
<Field k="Nº do pedido" v={order.numPedSar} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Itens ───────────────────────────────────────────────────────── */}
|
{/* ── Itens ───────────────────────────────────────────────────────── */}
|
||||||
<div style={{ padding: '8px 28px 0' }}>
|
<div style={{ padding: '0 28px 0' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: '#F4F7FB' }}>
|
<tr style={{ background: '#F4F7FB' }}>
|
||||||
@@ -372,13 +399,25 @@ export function OrderPrintPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Observações + rodapé ────────────────────────────────────────── */}
|
{/* ── Endereço de entrega + Observações ───────────────────────────── */}
|
||||||
|
{(order.endEntrega || order.obs) && (
|
||||||
|
<div style={{ padding: '10px 28px 0', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||||
|
{order.endEntrega && (
|
||||||
|
<div style={{ flex: 1, minWidth: 220 }}>
|
||||||
|
<span style={label}>Endereço de entrega</span>
|
||||||
|
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>
|
||||||
|
{order.endEntrega}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{order.obs && (
|
{order.obs && (
|
||||||
<div style={{ padding: '10px 28px 0' }}>
|
<div style={{ flex: 1, minWidth: 220 }}>
|
||||||
<span style={label}>Observações</span>
|
<span style={label}>Observações</span>
|
||||||
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div>
|
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
margin: '18px 28px 0',
|
margin: '18px 28px 0',
|
||||||
|
|||||||
639
apps/web/src/cockpits/rep/OrdersErpPage.tsx
Normal file
639
apps/web/src/cockpits/rep/OrdersErpPage.tsx
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Modal,
|
||||||
|
Input,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import {
|
||||||
|
CopyOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ClearOutlined,
|
||||||
|
ShoppingCartOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { PedidoErpConsultaItem } from '@sar/api-interface';
|
||||||
|
import { useOrderErpConsulta, useOrderDetail } from '../../lib/queries/orders';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
function fmt(v: number | string) {
|
||||||
|
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(v: string | null | undefined) {
|
||||||
|
if (!v) return '—';
|
||||||
|
return new Date(v).toLocaleDateString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status para registros tipo='E' (sig.pedidos entregas)
|
||||||
|
const SIG_STATUS_ENTREGA: Record<number, { label: string; color: string }> = {
|
||||||
|
1: { label: 'Pendente', color: 'orange' },
|
||||||
|
2: { label: 'Liberado', color: 'green' },
|
||||||
|
3: { label: 'Emitido', color: 'geekblue' },
|
||||||
|
5: { label: 'Cancelado', color: 'red' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusTag(row: PedidoErpConsultaItem) {
|
||||||
|
const cfg = row.tipo === 'E' ? SIG_STATUS_ENTREGA[row.situa] : undefined;
|
||||||
|
const label = row.statusDescr ?? cfg?.label ?? String(row.situa);
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
color={cfg?.color ?? 'default'}
|
||||||
|
style={{ borderRadius: 20, fontWeight: 600, fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal de detalhes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ErpConsultaModal({
|
||||||
|
row,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
row: PedidoErpConsultaItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { data: detail, isLoading } = useOrderDetail(
|
||||||
|
row?.idPedido ? `erp-${row.idPedido}` : undefined,
|
||||||
|
);
|
||||||
|
const { message: msg } = App.useApp();
|
||||||
|
|
||||||
|
const label: React.CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: '#94A3B8',
|
||||||
|
marginBottom: 2,
|
||||||
|
display: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
row ? (
|
||||||
|
<Space size={8}>
|
||||||
|
<Text strong style={{ color: '#003B8E' }}>
|
||||||
|
Pedido {row.numeroPedido}
|
||||||
|
</Text>
|
||||||
|
<Tag
|
||||||
|
color={row.tipo === 'P' ? 'blue' : 'cyan'}
|
||||||
|
style={{ fontWeight: 700, borderRadius: 20, margin: 0 }}
|
||||||
|
>
|
||||||
|
{row.tipo === 'P' ? 'Pedido' : 'Entrega'}
|
||||||
|
</Tag>
|
||||||
|
{row && statusTag(row)}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
'Detalhes'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
open={!!row}
|
||||||
|
onCancel={onClose}
|
||||||
|
centered
|
||||||
|
width={860}
|
||||||
|
destroyOnHidden
|
||||||
|
styles={{
|
||||||
|
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
|
||||||
|
}}
|
||||||
|
footer={<Button onClick={onClose}>Fechar</Button>}
|
||||||
|
>
|
||||||
|
{!row ? null : (
|
||||||
|
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||||
|
{/* ── Cabeçalho ── */}
|
||||||
|
<Card
|
||||||
|
styles={{ body: { padding: '14px 16px' } }}
|
||||||
|
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 10]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<span style={label}>Cliente</span>
|
||||||
|
<Text strong style={{ display: 'block' }}>
|
||||||
|
{row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`}
|
||||||
|
</Text>
|
||||||
|
{row.nomeCliente && row.razaoCliente && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{row.nomeCliente}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Data</span>
|
||||||
|
<Text>{fmtDate(row.data)}</Text>
|
||||||
|
</Col>
|
||||||
|
{row.dtPrevent && (
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Prev. Entrega</span>
|
||||||
|
<Text>{fmtDate(row.dtPrevent)}</Text>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Forma Pagto.</span>
|
||||||
|
<Text>{row.formaPagamento ?? '—'}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Total</span>
|
||||||
|
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
|
||||||
|
{fmt(row.total)}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
{row.numPedSar && (
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Nº SAR</span>
|
||||||
|
<Text style={{ color: '#64748B' }}>{row.numPedSar}</Text>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{row.obs && (
|
||||||
|
<Col span={24}>
|
||||||
|
<span style={label}>Observações</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{row.obs}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Dados de Entrega / NF (só tipo E) ── */}
|
||||||
|
{row.tipo === 'E' && (
|
||||||
|
<Card
|
||||||
|
styles={{ body: { padding: '14px 16px' } }}
|
||||||
|
style={{ borderRadius: 8, border: '1px solid #d1fae5', background: '#f0fdf4' }}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 10]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Nº Entrega</span>
|
||||||
|
<Text strong className="tabular-nums">
|
||||||
|
{row.numeroEntrega ?? '—'}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
{row.dataEmissao && (
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>Data Emissão</span>
|
||||||
|
<Text>{fmtDate(row.dataEmissao)}</Text>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{row.numeroNf && (
|
||||||
|
<Col span={8}>
|
||||||
|
<span style={label}>NF</span>
|
||||||
|
<Space size={4}>
|
||||||
|
<FileTextOutlined style={{ color: '#003B8E' }} />
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{row.numeroNf}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{row.chaveAcessoNfe && (
|
||||||
|
<Col span={24}>
|
||||||
|
<span style={label}>Chave NF-e</span>
|
||||||
|
<Space size={6} align="start">
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#475569',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.chaveAcessoNfe}
|
||||||
|
</Text>
|
||||||
|
<Tooltip title="Copiar chave">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(row.chaveAcessoNfe ?? '')
|
||||||
|
.then(() => void msg.success('Chave copiada'))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Itens do Pedido ── */}
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 32 }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : !detail?.itens?.length ? (
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Nenhum item encontrado para este registro.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={detail.itens}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
style={{ borderRadius: 8, border: '1px solid #EBF0F5' }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Produto',
|
||||||
|
key: 'produto',
|
||||||
|
render: (_: unknown, item) => (
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
|
||||||
|
<Text style={{ fontWeight: 500, fontSize: 13 }}>{item.descProduto}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Qtd.',
|
||||||
|
dataIndex: 'qtd',
|
||||||
|
width: 70,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => <Text className="tabular-nums">{Number(v)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Preço Un.',
|
||||||
|
dataIndex: 'precoUnitario',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string, item) => (
|
||||||
|
<Space orientation="vertical" size={0} style={{ alignItems: 'flex-end' }}>
|
||||||
|
<Text className="tabular-nums">{fmt(Number(v))}</Text>
|
||||||
|
{Number(item.descontoPerc) > 0 && (
|
||||||
|
<Text style={{ fontSize: 11, color: '#f59e0b' }}>
|
||||||
|
-{Number(item.descontoPerc)}%
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(Number(v))}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
summary={() => (
|
||||||
|
<Table.Summary.Row>
|
||||||
|
<Table.Summary.Cell index={0} colSpan={3} align="right">
|
||||||
|
<Text strong>Total</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={1} align="right">
|
||||||
|
<Text strong style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(detail.total)}
|
||||||
|
</Text>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
</Table.Summary.Row>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OrdersErpPage ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function OrdersErpPage() {
|
||||||
|
const { message: msg } = App.useApp();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [drawerRow, setDrawerRow] = useState<PedidoErpConsultaItem | null>(null);
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
const from = range ? range[0].format('YYYY-MM-DD') : undefined;
|
||||||
|
const to = range ? range[1].format('YYYY-MM-DD') : undefined;
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useOrderErpConsulta({
|
||||||
|
search: query || undefined,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const hasFilters = !!query || !!range;
|
||||||
|
|
||||||
|
function commitSearch() {
|
||||||
|
setQuery(search.trim());
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setSearch('');
|
||||||
|
setQuery('');
|
||||||
|
setRange(null);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<PedidoErpConsultaItem> = [
|
||||||
|
{
|
||||||
|
title: 'Pedido',
|
||||||
|
key: 'pedido',
|
||||||
|
width: 110,
|
||||||
|
render: (_: unknown, row: PedidoErpConsultaItem) => (
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{row.numeroPedido}
|
||||||
|
</Text>
|
||||||
|
{row.numPedSar && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{row.numPedSar}</Text>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tipo',
|
||||||
|
dataIndex: 'tipo',
|
||||||
|
key: 'tipo',
|
||||||
|
width: 90,
|
||||||
|
render: (tipo: 'P' | 'E') => (
|
||||||
|
<Tag color={tipo === 'P' ? 'blue' : 'cyan'} style={{ fontWeight: 700, borderRadius: 20 }}>
|
||||||
|
{tipo === 'P' ? 'Pedido' : 'Entrega'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Data',
|
||||||
|
dataIndex: 'data',
|
||||||
|
key: 'data',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => <Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
key: 'cliente',
|
||||||
|
minWidth: 220,
|
||||||
|
render: (_: unknown, row: PedidoErpConsultaItem) => {
|
||||||
|
const nome = row.razaoCliente ?? row.nomeCliente;
|
||||||
|
const sub = row.nomeCliente && row.razaoCliente ? row.nomeCliente : null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{nome ? (
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1F2937', display: 'block' }}>
|
||||||
|
{nome}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">Cód. {row.idCliente}</Text>
|
||||||
|
)}
|
||||||
|
{sub && <Text style={{ fontSize: 11, color: '#64748B', display: 'block' }}>{sub}</Text>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'situa',
|
||||||
|
key: 'status',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, row: PedidoErpConsultaItem) => statusTag(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
key: 'total',
|
||||||
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E', fontSize: 13 }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Forma Pagto.',
|
||||||
|
dataIndex: 'formaPagamento',
|
||||||
|
key: 'formaPagamento',
|
||||||
|
width: 140,
|
||||||
|
render: (v: string | null) => (
|
||||||
|
<Text style={{ fontSize: 12, color: '#475569' }}>{v ?? '—'}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Entrega / NF',
|
||||||
|
key: 'entrega',
|
||||||
|
width: 140,
|
||||||
|
render: (_: unknown, row: PedidoErpConsultaItem) => {
|
||||||
|
if (row.tipo !== 'E') return <Text type="secondary">—</Text>;
|
||||||
|
return (
|
||||||
|
<Space orientation="vertical" size={2}>
|
||||||
|
{row.numeroEntrega && (
|
||||||
|
<Text className="tabular-nums" style={{ fontSize: 12, color: '#475569' }}>
|
||||||
|
Entrega {row.numeroEntrega}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{row.numeroNf && (
|
||||||
|
<Space size={4}>
|
||||||
|
<FileTextOutlined style={{ color: '#003B8E', fontSize: 11 }} />
|
||||||
|
<Text strong className="tabular-nums" style={{ fontSize: 12, color: '#003B8E' }}>
|
||||||
|
NF {row.numeroNf}
|
||||||
|
</Text>
|
||||||
|
{row.chaveAcessoNfe && (
|
||||||
|
<Tooltip title={row.chaveAcessoNfe}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined style={{ fontSize: 10 }} />}
|
||||||
|
style={{ padding: '0 2px', height: 18, color: '#94A3B8' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(row.chaveAcessoNfe ?? '')
|
||||||
|
.then(() => void msg.success('Chave copiada'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 56,
|
||||||
|
render: (_: unknown, row: PedidoErpConsultaItem) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
style={{ borderRadius: 6 }}
|
||||||
|
disabled={!row.idPedido}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDrawerRow(row);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
Consulta de Pedidos ERP
|
||||||
|
</Title>
|
||||||
|
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
|
||||||
|
Pedidos e entregas no ERP. Apenas leitura.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid #EBF0F5',
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '14px 20px' } }}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 12]} align="middle">
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onPressEnter={commitSearch}
|
||||||
|
onBlur={commitSearch}
|
||||||
|
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
|
||||||
|
placeholder="Nº pedido ou cliente..."
|
||||||
|
allowClear
|
||||||
|
onClear={() => {
|
||||||
|
setSearch('');
|
||||||
|
setQuery('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<RangePicker
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={range}
|
||||||
|
format="DD/MM/YYYY"
|
||||||
|
allowClear
|
||||||
|
placeholder={['Data inicial', 'Data final']}
|
||||||
|
onChange={(dates) => {
|
||||||
|
if (dates && dates[0] && dates[1]) {
|
||||||
|
setRange([dates[0], dates[1]]);
|
||||||
|
} else {
|
||||||
|
setRange(null);
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} md={4}>
|
||||||
|
<Button
|
||||||
|
style={{ width: '100%', borderRadius: 6 }}
|
||||||
|
icon={<ClearOutlined />}
|
||||||
|
disabled={!hasFilters}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Limpar
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Text style={{ fontSize: 12, color: '#94A3B8' }}>
|
||||||
|
{data?.total !== undefined ? `${total.toLocaleString('pt-BR')} registros` : '…'}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 64 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '48px 0', textAlign: 'center' }}>
|
||||||
|
<ShoppingCartOutlined
|
||||||
|
style={{ fontSize: 56, color: '#D9E2EC', display: 'block', marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<Text strong style={{ fontSize: 15, display: 'block' }}>
|
||||||
|
Nenhum registro encontrado
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">Tente alterar os filtros de data ou busca.</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid #EBF0F5',
|
||||||
|
boxShadow: '0 1px 6px rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<PedidoErpConsultaItem>
|
||||||
|
rowKey={(row) => `${row.tipo}-${row.numeroPedido}-${row.numeroEntrega ?? 0}`}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={rows}
|
||||||
|
size="middle"
|
||||||
|
loading={isFetching}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
onRow={(row) => ({
|
||||||
|
onClick: () => row.idPedido && setDrawerRow(row),
|
||||||
|
style: {
|
||||||
|
background: row.tipo === 'E' ? '#f0fdf4' : '#fff',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
cursor: row.idPedido ? 'pointer' : 'default',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: limit,
|
||||||
|
total,
|
||||||
|
showSizeChanger: false,
|
||||||
|
showTotal: (t, [s, e]) => `Mostrando ${s}–${e} de ${t} registros`,
|
||||||
|
onChange: (p) => setPage(p),
|
||||||
|
style: { padding: '12px 24px' },
|
||||||
|
}}
|
||||||
|
style={{ borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ErpConsultaModal row={drawerRow} onClose={() => setDrawerRow(null)} />
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.ant-table-row:hover td { background: inherit !important; filter: brightness(0.97); }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Drawer,
|
Modal,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Grid,
|
Grid,
|
||||||
Row,
|
Row,
|
||||||
@@ -21,8 +22,6 @@ import {
|
|||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
@@ -43,6 +42,9 @@ import { SITUA_LABEL } from '@sar/api-interface';
|
|||||||
import { useOrderList, useOrderDetail } from '../../lib/queries/orders';
|
import { useOrderList, useOrderDetail } from '../../lib/queries/orders';
|
||||||
import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
|
import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
|
||||||
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
|
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
|
||||||
|
import { apiFetch } from '../../lib/api-client';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
@@ -77,6 +79,52 @@ function periodRange(p: string): { from?: string; to?: string } {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── WhatsApp ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WhatsAppIcon({ size = 14 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{ verticalAlign: '-2px' }}
|
||||||
|
>
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWhatsAppUrl(order: {
|
||||||
|
id: string;
|
||||||
|
numPedSar: string;
|
||||||
|
situa: number;
|
||||||
|
statusDescr?: string | null;
|
||||||
|
total: number | string;
|
||||||
|
dtPedido: string;
|
||||||
|
razaoCliente?: string | null;
|
||||||
|
nomeCliente?: string | null;
|
||||||
|
idCliente: number | string;
|
||||||
|
}): string {
|
||||||
|
const nome = order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`;
|
||||||
|
const status =
|
||||||
|
order.statusDescr ??
|
||||||
|
STATUS[order.situa]?.label ??
|
||||||
|
SITUA_LABEL[order.situa] ??
|
||||||
|
String(order.situa);
|
||||||
|
const pdfUrl = `${window.location.origin}/pedidos/${order.id}/imprimir`;
|
||||||
|
const text = [
|
||||||
|
`*Pedido ${order.numPedSar}*`,
|
||||||
|
`Cliente: ${nome}`,
|
||||||
|
`Data: ${fmtDate(order.dtPedido)}`,
|
||||||
|
`Total: ${fmt(order.total)}`,
|
||||||
|
`Status: ${status}`,
|
||||||
|
``,
|
||||||
|
`PDF: ${pdfUrl}`,
|
||||||
|
].join('\n');
|
||||||
|
return `https://wa.me/?text=${encodeURIComponent(text)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Status Config ────────────────────────────────────────────────────────────
|
// ─── Status Config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = {
|
const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = {
|
||||||
@@ -204,17 +252,27 @@ function OrderActionsMenu({
|
|||||||
onView: (id: string) => void;
|
onView: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const canDetail = order.fonte !== 'erp';
|
const qc = useQueryClient();
|
||||||
|
const { modal, message: msg } = App.useApp();
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: () => apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void msg.success('Orçamento cancelado.');
|
||||||
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const canCancel = order.situa === 0 && order.fonte !== 'erp';
|
||||||
|
|
||||||
const items: MenuProps['items'] = [
|
const items: MenuProps['items'] = [
|
||||||
canDetail
|
{
|
||||||
? {
|
|
||||||
key: 'view',
|
key: 'view',
|
||||||
icon: <EyeOutlined />,
|
icon: <EyeOutlined />,
|
||||||
label: 'Ver detalhes',
|
label: 'Ver detalhes',
|
||||||
onClick: () => onView(order.id),
|
onClick: () => onView(order.id),
|
||||||
}
|
},
|
||||||
: { key: 'view', icon: <EyeOutlined />, label: 'Ver detalhes', disabled: true },
|
|
||||||
{
|
{
|
||||||
key: 'duplicate',
|
key: 'duplicate',
|
||||||
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
|
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
|
||||||
@@ -227,16 +285,35 @@ function OrderActionsMenu({
|
|||||||
key: 'pdf',
|
key: 'pdf',
|
||||||
icon: <FilePdfOutlined />,
|
icon: <FilePdfOutlined />,
|
||||||
label: 'Gerar PDF',
|
label: 'Gerar PDF',
|
||||||
disabled: order.fonte === 'erp',
|
|
||||||
onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }),
|
onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'whatsapp',
|
||||||
|
icon: (
|
||||||
|
<span style={{ color: '#25D366' }}>
|
||||||
|
<WhatsAppIcon />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
label: <span style={{ color: '#25D366', fontWeight: 600 }}>Enviar pelo WhatsApp</span>,
|
||||||
|
onClick: () => window.open(buildWhatsAppUrl(order), '_blank'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'cancel',
|
key: 'cancel',
|
||||||
icon: <CloseCircleOutlined />,
|
icon: <CloseCircleOutlined />,
|
||||||
label: 'Cancelar pedido',
|
label: 'Cancelar pedido',
|
||||||
danger: true,
|
danger: true,
|
||||||
disabled: order.situa === 3,
|
disabled: !canCancel,
|
||||||
onClick: () => alert('Cancelamento em breve'),
|
onClick: () => {
|
||||||
|
if (!canCancel) return;
|
||||||
|
void modal.confirm({
|
||||||
|
title: 'Cancelar orçamento?',
|
||||||
|
content: `O pedido ${order.numPedSar} será marcado como cancelado. Esta ação não pode ser desfeita.`,
|
||||||
|
okText: 'Cancelar pedido',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: 'Voltar',
|
||||||
|
onOk: () => cancelMutation.mutate(),
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -247,10 +324,12 @@ function OrderActionsMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── OrderDetailDrawer ────────────────────────────────────────────────────────
|
// ─── OrderDetailModal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () => void }) {
|
function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
const { data, isLoading } = useOrderDetail(id ?? undefined);
|
const { data, isLoading } = useOrderDetail(id ?? undefined);
|
||||||
|
|
||||||
const timelineItems = (data?.historico ?? []).map((h) => ({
|
const timelineItems = (data?.historico ?? []).map((h) => ({
|
||||||
@@ -262,7 +341,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
) : (
|
) : (
|
||||||
<ClockCircleOutlined style={{ color: '#d46b08' }} />
|
<ClockCircleOutlined style={{ color: '#d46b08' }} />
|
||||||
),
|
),
|
||||||
children: (
|
content: (
|
||||||
<span style={{ fontSize: 13 }}>
|
<span style={{ fontSize: 13 }}>
|
||||||
<Text type="secondary">{new Date(h.changedAt).toLocaleString('pt-BR')}</Text>
|
<Text type="secondary">{new Date(h.changedAt).toLocaleString('pt-BR')}</Text>
|
||||||
{' — '}
|
{' — '}
|
||||||
@@ -290,16 +369,32 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Modal
|
||||||
title={data ? `Pedido ${data.numPedSar}` : 'Detalhes do Pedido'}
|
title={data ? `Pedido ${data.numPedSar}` : 'Detalhes do Pedido'}
|
||||||
open={!!id}
|
open={!!id}
|
||||||
onClose={onClose}
|
onCancel={onClose}
|
||||||
width={520}
|
centered
|
||||||
placement="right"
|
width={isMobile ? '95vw' : 860}
|
||||||
styles={{ body: { padding: '16px 24px' } }}
|
destroyOnHidden
|
||||||
|
styles={{
|
||||||
|
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
|
||||||
|
}}
|
||||||
footer={
|
footer={
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={onClose}>Fechar</Button>
|
<Button onClick={onClose}>Fechar</Button>
|
||||||
|
{data && (
|
||||||
|
<Button
|
||||||
|
icon={
|
||||||
|
<span style={{ color: '#25D366' }}>
|
||||||
|
<WhatsAppIcon />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
style={{ color: '#25D366', borderColor: '#25D366' }}
|
||||||
|
onClick={() => window.open(buildWhatsAppUrl(data), '_blank')}
|
||||||
|
>
|
||||||
|
WhatsApp
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{data && (
|
{data && (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -314,10 +409,10 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoading && <Spin style={{ display: 'block', marginTop: 48, textAlign: 'center' }} />}
|
{isLoading && <Spin style={{ display: 'block', margin: '48px auto' }} />}
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div>
|
<div>
|
||||||
<OrderStatusBadge situa={data.situa} descr={data.statusDescr} />
|
<OrderStatusBadge situa={data.situa} descr={data.statusDescr} />
|
||||||
@@ -329,15 +424,27 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
||||||
>
|
>
|
||||||
<Row gutter={[16, 10]}>
|
<Row gutter={[16, 10]}>
|
||||||
<Col span={12}>
|
<Col span={6}>
|
||||||
<span style={label}>Pedido</span>
|
<span style={label}>Pedido</span>
|
||||||
<Text strong>{data.numPedSar}</Text>
|
<Text strong>{data.numPedSar}</Text>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={6}>
|
||||||
<span style={label}>Data</span>
|
<span style={label}>Data</span>
|
||||||
<Text>{fmtDate(data.dtPedido)}</Text>
|
<Text>{fmtDate(data.dtPedido)}</Text>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={6}>
|
||||||
|
<span style={label}>Total</span>
|
||||||
|
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
|
||||||
|
{fmt(data.total)}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
{data.descontoPerc && Number(data.descontoPerc) > 0 && (
|
||||||
|
<Col span={6}>
|
||||||
|
<span style={label}>Desconto</span>
|
||||||
|
<Text>{Number(data.descontoPerc).toLocaleString('pt-BR')}%</Text>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
<Col span={12}>
|
||||||
<span style={label}>Cliente</span>
|
<span style={label}>Cliente</span>
|
||||||
<Text strong style={{ display: 'block' }}>
|
<Text strong style={{ display: 'block' }}>
|
||||||
{data.razaoCliente ?? data.nomeCliente ?? `Cód. ${data.idCliente}`}
|
{data.razaoCliente ?? data.nomeCliente ?? `Cód. ${data.idCliente}`}
|
||||||
@@ -348,20 +455,14 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={12}>
|
||||||
<span style={label}>Representante</span>
|
<span style={label}>Representante</span>
|
||||||
<Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text>
|
<Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
{data.endEntrega && (
|
||||||
<span style={label}>Total</span>
|
<Col span={24}>
|
||||||
<Text strong style={{ color: '#003B8E', fontSize: 16 }}>
|
<span style={label}>End. Entrega</span>
|
||||||
{fmt(data.total)}
|
<Text type="secondary">{data.endEntrega}</Text>
|
||||||
</Text>
|
|
||||||
</Col>
|
|
||||||
{data.descontoPerc && Number(data.descontoPerc) > 0 && (
|
|
||||||
<Col span={12}>
|
|
||||||
<span style={label}>Desconto</span>
|
|
||||||
<Text>{Number(data.descontoPerc).toLocaleString('pt-BR')}%</Text>
|
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
{data.obs && (
|
{data.obs && (
|
||||||
@@ -379,45 +480,62 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
<Text style={{ ...label, marginBottom: 8 }}>
|
<Text style={{ ...label, marginBottom: 8 }}>
|
||||||
Itens do Pedido ({data.itens.length})
|
Itens do Pedido ({data.itens.length})
|
||||||
</Text>
|
</Text>
|
||||||
{data.itens.map((item) => (
|
<Table
|
||||||
<div
|
rowKey="id"
|
||||||
key={item.id}
|
dataSource={data.itens}
|
||||||
style={{
|
size="small"
|
||||||
display: 'flex',
|
pagination={false}
|
||||||
justifyContent: 'space-between',
|
style={{ borderRadius: 8, border: '1px solid #EBF0F5' }}
|
||||||
alignItems: 'center',
|
columns={[
|
||||||
padding: '8px 12px',
|
{
|
||||||
borderRadius: 8,
|
title: 'Produto',
|
||||||
background: '#F8FAFC',
|
key: 'produto',
|
||||||
border: '1px solid #EBF0F5',
|
render: (_: unknown, item) => (
|
||||||
marginBottom: 6,
|
<Space orientation="vertical" size={0}>
|
||||||
}}
|
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
|
||||||
>
|
|
||||||
<Space direction="vertical" size={0}>
|
|
||||||
<Text style={{ fontSize: 12, color: '#64748B' }}>{item.codProduto}</Text>
|
|
||||||
<Text style={{ fontWeight: 500 }}>{item.descProduto}</Text>
|
<Text style={{ fontWeight: 500 }}>{item.descProduto}</Text>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{Number(item.qtd)} un × {fmt(Number(item.precoUnitario))}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
</Space>
|
||||||
<Text strong className="tabular-nums">
|
),
|
||||||
{fmt(Number(item.total))}
|
},
|
||||||
|
{
|
||||||
|
title: 'Qtd.',
|
||||||
|
dataIndex: 'qtd',
|
||||||
|
width: 70,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => <Text className="tabular-nums">{Number(v)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Preço Un.',
|
||||||
|
dataIndex: 'precoUnitario',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => <Text className="tabular-nums">{fmt(Number(v))}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total',
|
||||||
|
dataIndex: 'total',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(Number(v))}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
),
|
||||||
))}
|
},
|
||||||
<div
|
]}
|
||||||
style={{
|
summary={() => (
|
||||||
display: 'flex',
|
<Table.Summary.Row>
|
||||||
justifyContent: 'flex-end',
|
<Table.Summary.Cell index={0} colSpan={3} align="right">
|
||||||
marginTop: 8,
|
<Text strong>Total do Pedido</Text>
|
||||||
paddingTop: 8,
|
</Table.Summary.Cell>
|
||||||
borderTop: '1px solid #EBF0F5',
|
<Table.Summary.Cell index={1} align="right">
|
||||||
}}
|
<Text strong style={{ color: '#003B8E' }}>
|
||||||
>
|
{fmt(data.total)}
|
||||||
<Text strong style={{ fontSize: 15, color: '#003B8E' }}>
|
|
||||||
Total: {fmt(data.total)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</Table.Summary.Cell>
|
||||||
|
</Table.Summary.Row>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -430,7 +548,7 @@ function OrderDetailDrawer({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,7 +597,6 @@ function MobileOrderCard({
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EyeOutlined />}
|
icon={<EyeOutlined />}
|
||||||
disabled={order.fonte === 'erp'}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
order.situa === 0
|
order.situa === 0
|
||||||
? void navigate({ to: '/pedidos/$id', params: { id: order.id } })
|
? void navigate({ to: '/pedidos/$id', params: { id: order.id } })
|
||||||
@@ -499,6 +616,18 @@ function MobileOrderCard({
|
|||||||
>
|
>
|
||||||
Duplicar
|
Duplicar
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={
|
||||||
|
<span style={{ color: '#25D366' }}>
|
||||||
|
<WhatsAppIcon />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
style={{ borderRadius: 6 }}
|
||||||
|
onClick={() => window.open(buildWhatsAppUrl(order), '_blank')}
|
||||||
|
>
|
||||||
|
WhatsApp
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -538,7 +667,7 @@ export function OrdersPage() {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = data?.data ?? [];
|
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
|
|
||||||
const hasFilters = !!query || !!situaFilter || !!period || !!range;
|
const hasFilters = !!query || !!situaFilter || !!period || !!range;
|
||||||
@@ -566,20 +695,13 @@ export function OrdersPage() {
|
|||||||
title: 'Pedido',
|
title: 'Pedido',
|
||||||
key: 'pedido',
|
key: 'pedido',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_: unknown, row: PedidoSummary) => {
|
render: (_: unknown, row: PedidoSummary) => (
|
||||||
const label = row.numero ? String(row.numero) : row.numPedSar;
|
|
||||||
return row.fonte === 'erp' ? (
|
|
||||||
<Text strong className="tabular-nums" style={{ color: '#1F2937' }}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||||
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
{label}
|
{row.numPedSar}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Cliente',
|
title: 'Cliente',
|
||||||
@@ -651,7 +773,6 @@ export function OrdersPage() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
style={{ borderRadius: 6 }}
|
style={{ borderRadius: 6 }}
|
||||||
title="Ver detalhes"
|
title="Ver detalhes"
|
||||||
disabled={row.fonte === 'erp'}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
row.situa === 0
|
row.situa === 0
|
||||||
? void navigate({ to: '/pedidos/$id', params: { id: row.id } })
|
? void navigate({ to: '/pedidos/$id', params: { id: row.id } })
|
||||||
@@ -962,13 +1083,12 @@ export function OrdersPage() {
|
|||||||
scroll={{ x: 900 }}
|
scroll={{ x: 900 }}
|
||||||
onRow={(row) => ({
|
onRow={(row) => ({
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
// Orçamento abre a tela grande de detalhe (com Transmitir); demais, o drawer.
|
|
||||||
if (row.situa === 0) void navigate({ to: '/pedidos/$id', params: { id: row.id } });
|
if (row.situa === 0) void navigate({ to: '/pedidos/$id', params: { id: row.id } });
|
||||||
else if (row.fonte !== 'erp') setDrawerOrderId(row.id);
|
else setDrawerOrderId(row.id);
|
||||||
},
|
},
|
||||||
style: {
|
style: {
|
||||||
background: STATUS[row.situa]?.rowBg ?? '#fff',
|
background: STATUS[row.situa]?.rowBg ?? '#fff',
|
||||||
cursor: row.fonte !== 'erp' ? 'pointer' : 'default',
|
cursor: 'pointer',
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
@@ -997,7 +1117,7 @@ export function OrdersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Drawer de detalhe ─────────────────────────────────────────── */}
|
{/* ── Drawer de detalhe ─────────────────────────────────────────── */}
|
||||||
<OrderDetailDrawer id={drawerOrderId} onClose={() => setDrawerOrderId(null)} />
|
<OrderDetailModal id={drawerOrderId} onClose={() => setDrawerOrderId(null)} />
|
||||||
|
|
||||||
{/* FAB mobile */}
|
{/* FAB mobile */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
|||||||
427
apps/web/src/cockpits/rep/ProductDetailDrawer.tsx
Normal file
427
apps/web/src/cockpits/rep/ProductDetailDrawer.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd';
|
||||||
|
import {
|
||||||
|
BarcodeOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
PercentageOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { PautaPreco } from '@sar/api-interface';
|
||||||
|
import { useProdutoDetail } from '../../lib/queries/catalog';
|
||||||
|
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
|
||||||
|
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
function fmtPrice(v: string | null | undefined): string {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(v: string | null | undefined, decimals = 3): string {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
return n > 0
|
||||||
|
? n.toLocaleString('pt-BR', {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
})
|
||||||
|
: '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bloco de seção ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<span style={{ color: '#003B8E', fontSize: 16 }}>{icon}</span>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#374151',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Campo label/valor ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
span = 1,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
span?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ gridColumn: span > 1 ? 'span 2' : undefined, marginBottom: 12 }}>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', marginBottom: 2 }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13, color: '#1F2937' }}>{value ?? '—'}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card de preço ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PrecoCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
const n = Number(value ?? 0);
|
||||||
|
const empty = n <= 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: highlight && !empty ? '#F0FDF4' : '#F8FAFC',
|
||||||
|
border: `1px solid ${highlight && !empty ? '#BBF7D0' : '#EBF0F5'}`,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', marginBottom: 4 }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
color: empty ? '#CBD5E1' : highlight ? '#16A34A' : '#1F2937',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtPrice(value)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lista de pautas ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PautaPrecoList({ items }: { items: PautaPreco[] }) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '24px 16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: '#F8FAFC',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px dashed #E2E8F0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Produto não está em nenhuma pauta ativa.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.idPauta}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#F8FAFC',
|
||||||
|
border: '1px solid #EBF0F5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
|
||||||
|
Pauta {item.codigo}
|
||||||
|
</Text>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
|
||||||
|
{item.descricao}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ fontSize: 16, color: '#16A34A', fontVariantNumeric: 'tabular-nums' }}
|
||||||
|
>
|
||||||
|
{fmtPrice(item.preco)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Condições comerciais vigentes ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmtData(iso: string): string {
|
||||||
|
const [y, m, d] = iso.split('-');
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CondicoesList({ conds }: { conds: CondicaoComercial[] }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{conds.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: c.tipo === 'promocao' ? '#FFFBE6' : '#F0F5FF',
|
||||||
|
border: `1px solid ${c.tipo === 'promocao' ? '#FFE58F' : '#ADC6FF'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
|
||||||
|
{c.tipo === 'promocao' ? 'Promoção' : 'Condição especial'} · válida até{' '}
|
||||||
|
{fmtData(c.dataFim)}
|
||||||
|
</Text>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
|
||||||
|
{c.descricao}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: c.tipo === 'promocao' ? '#D48806' : '#2F54EB',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.descPct.toLocaleString('pt-BR')}% desc.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal principal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
idErp: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
||||||
|
const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
|
||||||
|
const condicoesDe = useCondicoesComerciais();
|
||||||
|
const conds = data ? condicoesDe(data) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={idErp != null}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={1100}
|
||||||
|
centered
|
||||||
|
title={null}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 14 }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── Cabeçalho ──────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '24px 32px 20px',
|
||||||
|
borderBottom: '1px solid #F1F5F9',
|
||||||
|
background: '#FAFBFC',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text style={{ fontSize: 12, color: '#94A3B8', display: 'block', marginBottom: 4 }}>
|
||||||
|
Código {data.codigo.trim()}
|
||||||
|
{data.referencia ? ` · Ref. ${data.referencia.trim()}` : ''}
|
||||||
|
</Text>
|
||||||
|
<Title level={4} style={{ margin: 0, color: '#1F2937', lineHeight: 1.3 }}>
|
||||||
|
{data.descricao.trim()}
|
||||||
|
</Title>
|
||||||
|
{data.descricaoDetalhada && (
|
||||||
|
<Text style={{ fontSize: 13, color: '#64748B', display: 'block', marginTop: 4 }}>
|
||||||
|
{data.descricaoDetalhada.trim()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CondicaoTag conds={conds} />
|
||||||
|
{data.marca && <Tag color="blue">{data.marca.trim()}</Tag>}
|
||||||
|
{data.unidade && <Tag>{data.unidade}</Tag>}
|
||||||
|
<Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Corpo ──────────────────────────────────────────────── */}
|
||||||
|
<div style={{ padding: '28px 32px', maxHeight: '65vh', overflowY: 'auto' }}>
|
||||||
|
<Row gutter={40}>
|
||||||
|
{/* Coluna esquerda */}
|
||||||
|
<Col xs={24} md={14}>
|
||||||
|
{/* Condições comerciais vigentes */}
|
||||||
|
{conds.length > 0 && (
|
||||||
|
<Section icon={<PercentageOutlined />} title="Condições Comerciais">
|
||||||
|
<CondicoesList conds={conds} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Classificação */}
|
||||||
|
<Section icon={<TagsOutlined />} title="Classificação">
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||||
|
<Field label="Grupo" value={data.grupo?.trim() ?? '—'} />
|
||||||
|
<Field label="Subgrupo" value={data.subgrupo?.trim() ?? '—'} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Preços Base */}
|
||||||
|
<Section icon={<TagsOutlined />} title="Preços Base">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PrecoCard label="Preço 1" value={data.vlPreco1} highlight />
|
||||||
|
<PrecoCard label="Preço 2" value={data.vlPreco2 ?? '0'} />
|
||||||
|
<PrecoCard label="Preço 3" value={data.vlPreco3 ?? '0'} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
|
||||||
|
<PrecoCard label="Promocional" value={data.precoPromocional ?? '0'} />
|
||||||
|
<PrecoCard label="Com IPI" value={data.precoComIpi ?? '0'} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#F8FAFC',
|
||||||
|
border: '1px solid #EBF0F5',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#94A3B8',
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alíq. IPI
|
||||||
|
</Text>
|
||||||
|
<Text strong style={{ fontSize: 15, color: '#1F2937' }}>
|
||||||
|
{data.aliqIpi ? `${Number(data.aliqIpi).toLocaleString('pt-BR')}%` : '—'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Estoque & Logística */}
|
||||||
|
<Section icon={<InboxOutlined />} title="Estoque & Logística">
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Estoque"
|
||||||
|
value={data.qtdEstoque != null ? Number(data.qtdEstoque) : undefined}
|
||||||
|
valueStyle={{
|
||||||
|
fontSize: 22,
|
||||||
|
color:
|
||||||
|
data.qtdEstoque == null
|
||||||
|
? '#CBD5E1'
|
||||||
|
: Number(data.qtdEstoque) > 0
|
||||||
|
? '#1F2937'
|
||||||
|
: '#EF4444',
|
||||||
|
}}
|
||||||
|
formatter={(v) => (v != null ? Number(v).toLocaleString('pt-BR') : '—')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Lote múlt."
|
||||||
|
value={data.loteMulVenda ?? undefined}
|
||||||
|
valueStyle={{ fontSize: 22, color: '#1F2937' }}
|
||||||
|
formatter={(v) => (v != null ? String(v) : '—')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Peso líq. (kg)"
|
||||||
|
value={data.pesoLiquido ?? undefined}
|
||||||
|
valueStyle={{ fontSize: 22, color: '#1F2937' }}
|
||||||
|
formatter={(v) => fmtNum(String(v))}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Qtd. volume"
|
||||||
|
value={data.qtdVolume ?? undefined}
|
||||||
|
valueStyle={{ fontSize: 22, color: '#1F2937' }}
|
||||||
|
formatter={(v) => fmtNum(String(v), 0)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Divisor vertical */}
|
||||||
|
<Col md={0} xs={24}>
|
||||||
|
<Divider style={{ margin: '4px 0 24px' }} />
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Coluna direita — Pautas */}
|
||||||
|
<Col xs={24} md={10}>
|
||||||
|
<Section
|
||||||
|
icon={<BarcodeOutlined />}
|
||||||
|
title={`Preços por Pauta (${data.pautaPrecos.length})`}
|
||||||
|
>
|
||||||
|
<PautaPrecoList items={data.pautaPrecos} />
|
||||||
|
</Section>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
589
apps/web/src/cockpits/rep/RelatoriosPage.tsx
Normal file
589
apps/web/src/cockpits/rep/RelatoriosPage.tsx
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
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, isError, error } = 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>
|
||||||
|
) : isError ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Erro ao carregar desempenho"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Space orientation="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}
|
||||||
|
styles={{ content: { color: '#003B8E' } }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="Comissão Estimada"
|
||||||
|
value={fmt(comissao)}
|
||||||
|
styles={{ content: { color: '#52c41a', fontSize: 20 } }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="Falta para Meta"
|
||||||
|
value={meta > realizado ? fmt(meta - realizado) : 'Atingida'}
|
||||||
|
styles={{
|
||||||
|
content: { color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="% Atingido"
|
||||||
|
value={`${percentual.toFixed(1)}%`}
|
||||||
|
styles={{ content: { 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, isError, error } = 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 orientation="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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Erro ao carregar carteira de clientes"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data && (
|
||||||
|
<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}
|
||||||
|
styles={{ content: { 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, isError, error } = 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 orientation="vertical" size={0}>
|
||||||
|
{p.codProduto && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{p.codProduto}</Text>}
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: 500 }}>{p.descProduto}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total Faturado',
|
||||||
|
dataIndex: 'totalFaturado',
|
||||||
|
width: 150,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string, p: AbcProduto) => (
|
||||||
|
<Space orientation="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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Erro ao carregar curva ABC"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 20 }}>
|
||||||
|
<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,18 +1,55 @@
|
|||||||
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 {
|
||||||
faArrowTrendUp,
|
faArrowTrendUp,
|
||||||
faBullseye,
|
faBullseye,
|
||||||
|
faChartBar,
|
||||||
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 {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip as ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Chart } from 'react-chartjs-2';
|
||||||
|
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } 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';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const SITUA_COLOR: Record<number, string> = {
|
const SITUA_COLOR: Record<number, string> = {
|
||||||
@@ -34,17 +71,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 +170,273 @@ const metaColumns: TableColumnsType<MetaItem> = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── Gráfico anual ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MESES = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
|
||||||
|
|
||||||
|
function GraficoAnual({
|
||||||
|
dados,
|
||||||
|
ano,
|
||||||
|
mesAtual,
|
||||||
|
}: {
|
||||||
|
dados: MesAno[];
|
||||||
|
ano: number;
|
||||||
|
mesAtual: number;
|
||||||
|
}) {
|
||||||
|
const labels = MESES;
|
||||||
|
|
||||||
|
const barColors = dados.map((d) => {
|
||||||
|
if (d.mes > mesAtual) return 'rgba(203,213,225,0.5)'; // futuro — cinza claro
|
||||||
|
if (d.mes === mesAtual) return d.atingiu ? 'rgba(56,158,13,0.75)' : 'rgba(0,59,142,0.75)'; // mês atual
|
||||||
|
return d.atingiu ? 'rgba(56,158,13,0.85)' : 'rgba(0,59,142,0.65)'; // passado
|
||||||
|
});
|
||||||
|
|
||||||
|
const borderColors = dados.map((d) => (d.mes === mesAtual ? '#1a1a1a' : 'transparent'));
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'bar' as const,
|
||||||
|
label: 'Realizado',
|
||||||
|
data: dados.map((d) => (d.mes <= mesAtual ? d.valor : null)),
|
||||||
|
backgroundColor: barColors,
|
||||||
|
borderColor: borderColors,
|
||||||
|
borderWidth: dados.map((d) => (d.mes === mesAtual ? 2 : 0)),
|
||||||
|
borderRadius: 4,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'line' as const,
|
||||||
|
label: 'Meta',
|
||||||
|
data: dados.map((d) => (d.meta > 0 ? d.meta : null)),
|
||||||
|
borderColor: '#f5222d',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 4],
|
||||||
|
pointRadius: dados.map((d) => (d.meta > 0 ? 4 : 0)),
|
||||||
|
pointBackgroundColor: dados.map((d) =>
|
||||||
|
d.meta > 0 && d.mes <= mesAtual ? (d.atingiu ? '#52c41a' : '#f5222d') : '#f5222d',
|
||||||
|
),
|
||||||
|
order: 1,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index' as const, intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
||||||
|
const val = ctx.parsed.y;
|
||||||
|
if (val == null) return '';
|
||||||
|
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
callback: (v: number | string) =>
|
||||||
|
Number(v).toLocaleString('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
notation: 'compact',
|
||||||
|
}),
|
||||||
|
font: { size: 10 },
|
||||||
|
},
|
||||||
|
grid: { color: '#f0f0f0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Realizado vs Meta — {ano}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ height: 240 }}>
|
||||||
|
<Chart type="bar" data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 orientation="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 +446,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>
|
||||||
@@ -154,8 +457,20 @@ export function RepPainel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { meta, metasPorGrupo, comissao, pedidosMes, pedidosRecentes, clientesInativos, syncedAt } =
|
const {
|
||||||
data;
|
meta,
|
||||||
|
metasPorGrupo = [],
|
||||||
|
pedidosMes,
|
||||||
|
pedidosRecentes = [],
|
||||||
|
naoPositivados = [],
|
||||||
|
totalNaoPositivados = 0,
|
||||||
|
historicoMensal = [],
|
||||||
|
syncedAt,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const anoAtual = now.getFullYear();
|
||||||
|
const mesAtual = now.getMonth() + 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
@@ -166,21 +481,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">
|
||||||
@@ -205,7 +520,7 @@ export function RepPainel() {
|
|||||||
percent={Math.min(meta.pct, 100)}
|
percent={Math.min(meta.pct, 100)}
|
||||||
showInfo={false}
|
showInfo={false}
|
||||||
strokeColor="var(--jcs-blue)"
|
strokeColor="var(--jcs-blue)"
|
||||||
trailColor="var(--jcs-blue-light)"
|
railColor="var(--jcs-blue-light)"
|
||||||
/>
|
/>
|
||||||
{meta.falta > 0 ? (
|
{meta.falta > 0 ? (
|
||||||
<Text style={{ fontSize: 'var(--text-md)' }}>
|
<Text style={{ fontSize: 'var(--text-md)' }}>
|
||||||
@@ -221,8 +536,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
|
||||||
@@ -231,32 +546,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={
|
||||||
@@ -332,67 +629,16 @@ export function RepPainel() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
|
{/* Gráfico anual */}
|
||||||
|
<GraficoAnual dados={historicoMensal} ano={anoAtual} mesAtual={mesAtual} />
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|||||||
27
apps/web/src/components/CondicaoTag.tsx
Normal file
27
apps/web/src/components/CondicaoTag.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Tag } from 'antd';
|
||||||
|
import type { CondicaoComercial } from '../lib/condicoes-comerciais';
|
||||||
|
|
||||||
|
// Selo que sinaliza produto com proposta comercial diferenciada (promoção do
|
||||||
|
// gerente ou regra de desconto vigente). O detalhamento fica no modal do produto.
|
||||||
|
export function CondicaoTag({ conds, compact }: { conds: CondicaoComercial[]; compact?: boolean }) {
|
||||||
|
if (conds.length === 0) return null;
|
||||||
|
const temPromo = conds.some((c) => c.tipo === 'promocao');
|
||||||
|
const temRegra = conds.some((c) => c.tipo === 'regra');
|
||||||
|
const style = compact
|
||||||
|
? { fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '16px' }
|
||||||
|
: { fontSize: 11, margin: 0 };
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{temPromo && (
|
||||||
|
<Tag color="gold" style={style}>
|
||||||
|
Promoção
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{temRegra && (
|
||||||
|
<Tag color="geekblue" style={style}>
|
||||||
|
Cond. especial
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
apps/web/src/components/contacts/ClientContacts.tsx
Normal file
248
apps/web/src/components/contacts/ClientContacts.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import {
|
||||||
|
App,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
DatePicker,
|
||||||
|
Divider,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { MailOutlined, PhoneOutlined, WhatsAppOutlined } from '@ant-design/icons';
|
||||||
|
import type { Contato } from '@sar/api-interface';
|
||||||
|
import { useClientContacts, useCreateContato } from '../../lib/queries/clients';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
function ContactActions({ contato }: { contato: Contato }) {
|
||||||
|
const wa = contato.whatsapp?.trim() || contato.celular?.trim();
|
||||||
|
const tel = contato.telefone?.trim();
|
||||||
|
const mail = contato.email?.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{wa && (
|
||||||
|
<Tooltip title={`WhatsApp: ${wa}`}>
|
||||||
|
<a href={`https://wa.me/55${wa.replace(/\D/g, '')}`} target="_blank" rel="noreferrer">
|
||||||
|
<Tag
|
||||||
|
icon={<WhatsAppOutlined />}
|
||||||
|
color="#25D366"
|
||||||
|
style={{ cursor: 'pointer', margin: 0 }}
|
||||||
|
>
|
||||||
|
{wa}
|
||||||
|
</Tag>
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{tel && !wa && (
|
||||||
|
<Tooltip title={`Telefone: ${tel}`}>
|
||||||
|
<Tag icon={<PhoneOutlined />} color="blue" style={{ margin: 0 }}>
|
||||||
|
{tel}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{mail && (
|
||||||
|
<Tooltip title={mail}>
|
||||||
|
<a href={`mailto:${mail}`}>
|
||||||
|
<Tag icon={<MailOutlined />} color="default" style={{ margin: 0 }}>
|
||||||
|
{mail.length > 22 ? mail.slice(0, 20) + '…' : mail}
|
||||||
|
</Tag>
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
idCliente: number;
|
||||||
|
modalOpen?: boolean;
|
||||||
|
onModalClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: Props) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const open = modalOpen;
|
||||||
|
const setOpen = (v: boolean) => {
|
||||||
|
if (!v) onModalClose?.();
|
||||||
|
};
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const { data: contacts = [], isLoading } = useClientContacts(idCliente);
|
||||||
|
const createMutation = useCreateContato(idCliente);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const payload = {
|
||||||
|
nome: String(values.nome),
|
||||||
|
empresa: values.empresa || undefined,
|
||||||
|
cargo: values.cargo || undefined,
|
||||||
|
departamento: values.departamento || undefined,
|
||||||
|
dtAniversario: values.dtAniversario
|
||||||
|
? (values.dtAniversario as { format: (f: string) => string }).format('YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
telefone: values.telefone || undefined,
|
||||||
|
celular: values.celular || undefined,
|
||||||
|
whatsapp: values.whatsapp || undefined,
|
||||||
|
email: values.email || undefined,
|
||||||
|
anotacoes: values.anotacoes || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||||
|
try {
|
||||||
|
result = await createMutation.mutateAsync(payload);
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.sincronizado && result.erroSync) {
|
||||||
|
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);
|
||||||
|
} else {
|
||||||
|
void message.success(`Contato cadastrado! Código ERP: ${result.idContatoErp ?? '—'}`);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider titlePlacement="left" style={{ marginBottom: 12 }}>
|
||||||
|
Contatos
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Spin style={{ display: 'block', margin: '16px auto' }} />
|
||||||
|
) : contacts.length === 0 ? (
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ display: 'block', marginBottom: 24, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
Nenhum contato cadastrado.
|
||||||
|
</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
|
||||||
|
title="Novo Contato"
|
||||||
|
open={open}
|
||||||
|
onOk={() => void handleSave()}
|
||||||
|
onCancel={() => {
|
||||||
|
setOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
okText="Cadastrar"
|
||||||
|
cancelText="Cancelar"
|
||||||
|
width={580}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }} requiredMark="optional">
|
||||||
|
<Form.Item
|
||||||
|
name="nome"
|
||||||
|
label="Nome"
|
||||||
|
rules={[{ required: true, message: 'Informe o nome' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Nome do contato" maxLength={100} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="telefone" label="Telefone fixo">
|
||||||
|
<Input placeholder="4833330000" maxLength={20} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="email" label="E-mail">
|
||||||
|
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="dtAniversario" label="Aniversário">
|
||||||
|
<DatePicker
|
||||||
|
format="DD/MM/YYYY"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="dd/mm/aaaa"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item name="anotacoes" label="Anotações">
|
||||||
|
<Input.TextArea rows={2} placeholder="Observações sobre o contato..." />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,14 @@ import { AuthTokenResponseSchema } from '@sar/api-interface';
|
|||||||
|
|
||||||
type DevUser = { key: string; userId: string; role: string; label: string };
|
type DevUser = { key: string; userId: string; role: string; label: string };
|
||||||
|
|
||||||
// userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1)
|
// userId = cod_vendedor como string; idEmpresa fica a cargo do backend (DEV_EMPRESA_ID).
|
||||||
// Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado.
|
// Usuários provisórios para testar as telas por papel enquanto o cadastro de
|
||||||
|
// usuários não existe: Pavei (carteira própria), Sidnei (equipe via
|
||||||
|
// vw_representantes.cod_supervisor = 191), Lucas (empresa toda).
|
||||||
const DEV_USERS: DevUser[] = [
|
const DEV_USERS: DevUser[] = [
|
||||||
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' },
|
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Pavei — Representante (cód. 29)' },
|
||||||
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' },
|
{ key: 'sup-191', userId: '191', role: 'supervisor', label: 'Sidnei — Supervisor (cód. 191)' },
|
||||||
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' },
|
{ key: 'mgr-156', userId: '156', role: 'manager', label: 'Lucas — Gerente/Admin (cód. 156)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
||||||
@@ -27,7 +29,7 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
|||||||
try {
|
try {
|
||||||
const raw = await apiFetch('/auth/dev/token', {
|
const raw = await apiFetch('/auth/dev/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { userId: user.userId, idEmpresa: 1, role: user.role },
|
body: { userId: user.userId, role: user.role },
|
||||||
});
|
});
|
||||||
const { accessToken } = AuthTokenResponseSchema.parse(raw);
|
const { accessToken } = AuthTokenResponseSchema.parse(raw);
|
||||||
authStore.set(accessToken);
|
authStore.set(accessToken);
|
||||||
|
|||||||
@@ -1,29 +1,36 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useEffect, type ReactNode } from 'react';
|
||||||
import { Alert, Button, Flex, Tooltip } from 'antd';
|
import { Alert, App, Button, Flex, Grid, Tooltip } from 'antd';
|
||||||
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Topbar } from './Topbar';
|
import { Topbar } from './Topbar';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { BottomNav } from './BottomNav';
|
||||||
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
||||||
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
||||||
|
import { registerMessageApi } from '../../lib/feedback';
|
||||||
|
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* AppShell canônico do SAR — topbar 80 + sidebar 260 + área de conteúdo.
|
|
||||||
* Layout para cockpits Sandra/Daniel/Alice (desktop-first).
|
|
||||||
* Variante mobile (Rafael) com bottom nav virá em ShellMobile separado.
|
|
||||||
*/
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
const [, setSidebarOpen] = useState(true);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isOnline = useNetworkStatus();
|
const isOnline = useNetworkStatus();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
|
||||||
|
const isDesktop = !!screens.lg;
|
||||||
useOfflineSync();
|
useOfflineSync();
|
||||||
|
|
||||||
|
// Disponibiliza o message (com tema) para os caches do TanStack Query
|
||||||
|
useEffect(() => {
|
||||||
|
registerMessageApi(message);
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
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"
|
||||||
@@ -34,22 +41,28 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
style={{ padding: '6px 16px', fontSize: 13 }}
|
style={{ padding: '6px 16px', fontSize: 13 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
|
<Topbar />
|
||||||
<Flex flex={1}>
|
<Flex flex={1} style={{ overflow: 'hidden' }}>
|
||||||
<Sidebar />
|
{isDesktop && <Sidebar />}
|
||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 'var(--space-xl)',
|
padding: isDesktop ? 'var(--space-xl)' : 'var(--space-md)',
|
||||||
overflow: 'auto',
|
paddingBottom: isDesktop
|
||||||
maxWidth: '100%',
|
? 'var(--space-xl)'
|
||||||
|
: 'calc(var(--layout-bottom-nav-height) + var(--space-lg))',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* FAB — Novo Pedido */}
|
{!isDesktop && <BottomNav />}
|
||||||
|
|
||||||
|
{/* FAB — Novo Pedido: acima do bottom nav em mobile */}
|
||||||
<Tooltip title="Novo Pedido" placement="left">
|
<Tooltip title="Novo Pedido" placement="left">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -58,8 +71,8 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
onClick={() => void navigate({ to: '/pedidos/novo' })}
|
onClick={() => void navigate({ to: '/pedidos/novo' })}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 32,
|
bottom: isDesktop ? 32 : 'calc(var(--layout-bottom-nav-height) + 16px)',
|
||||||
right: 32,
|
right: 20,
|
||||||
width: 52,
|
width: 52,
|
||||||
height: 52,
|
height: 52,
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
|
|||||||
119
apps/web/src/components/layout/BottomNav.tsx
Normal file
119
apps/web/src/components/layout/BottomNav.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faChartLine,
|
||||||
|
faClipboardList,
|
||||||
|
faClipboardCheck,
|
||||||
|
faUsers,
|
||||||
|
faAddressCard,
|
||||||
|
faBoxesStacked,
|
||||||
|
faFileInvoiceDollar,
|
||||||
|
faTags,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { authStore } from '../../lib/auth-store';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
key: string;
|
||||||
|
icon: IconDefinition;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRole(): string {
|
||||||
|
const token = authStore.get();
|
||||||
|
if (!token) return 'rep';
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
|
||||||
|
return (payload.role as string) ?? 'rep';
|
||||||
|
} catch {
|
||||||
|
return 'rep';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REP_ITEMS: NavItem[] = [
|
||||||
|
{ key: '/', icon: faChartLine, label: 'Painel' },
|
||||||
|
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||||
|
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
|
||||||
|
{ key: '/catalogo', icon: faBoxesStacked, label: 'Catálogo' },
|
||||||
|
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUPERVISOR_ITEMS: NavItem[] = [
|
||||||
|
{ key: '/', icon: faChartLine, label: 'Painel' },
|
||||||
|
{ key: '/aprovacoes', icon: faClipboardCheck, label: 'Aprovações' },
|
||||||
|
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
|
||||||
|
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||||
|
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GERENTE_ITEMS: NavItem[] = [
|
||||||
|
{ key: '/ger', icon: faChartLine, label: 'Painel' },
|
||||||
|
{ key: '/ger/equipe', icon: faUsers, label: 'Equipe' },
|
||||||
|
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||||
|
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||||
|
{ key: '/ger/politicas', icon: faTags, label: 'Políticas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getItems(role: string): NavItem[] {
|
||||||
|
if (role === 'manager' || role === 'admin') return GERENTE_ITEMS;
|
||||||
|
if (role === 'supervisor') return SUPERVISOR_ITEMS;
|
||||||
|
return REP_ITEMS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const role = getRole();
|
||||||
|
const items = getItems(role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 'var(--layout-bottom-nav-height)',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
borderTop: '1px solid var(--border-subtle)',
|
||||||
|
display: 'flex',
|
||||||
|
zIndex: 'var(--z-fixed)' as unknown as number,
|
||||||
|
boxShadow: '0 -2px 12px rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.key === '/' || item.key === '/ger'
|
||||||
|
? location.pathname === item.key
|
||||||
|
: location.pathname.startsWith(item.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => void navigate({ to: item.key })}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 3,
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '6px 4px',
|
||||||
|
color: isActive ? 'var(--jcs-blue)' : 'var(--text-muted)',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
transition: 'color var(--duration-fast) var(--easing-standard)',
|
||||||
|
WebkitTapHighlightColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={item.icon} style={{ fontSize: 20 }} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,61 +4,92 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import {
|
import {
|
||||||
faChartLine,
|
faChartLine,
|
||||||
faClipboardList,
|
faClipboardList,
|
||||||
|
faClipboardCheck,
|
||||||
faFunnelDollar,
|
faFunnelDollar,
|
||||||
faCalendarDays,
|
|
||||||
faUsers,
|
faUsers,
|
||||||
|
faAddressCard,
|
||||||
faBoxesStacked,
|
faBoxesStacked,
|
||||||
faGear,
|
|
||||||
faPercent,
|
|
||||||
faFileInvoiceDollar,
|
faFileInvoiceDollar,
|
||||||
|
faTags,
|
||||||
|
faHeadset,
|
||||||
} 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';
|
||||||
|
import { authStore } from '../../lib/auth-store';
|
||||||
|
|
||||||
/**
|
function getRole(): string {
|
||||||
* Sidebar canônica do SAR (260px fixa — brand.md).
|
const token = authStore.get();
|
||||||
* Itens mockados para Rafael cockpit. Variantes por cockpit virão depois.
|
if (!token) return 'rep';
|
||||||
*/
|
try {
|
||||||
export function Sidebar() {
|
const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
|
||||||
const navigate = useNavigate();
|
return (payload.role as string) ?? 'rep';
|
||||||
const location = useLocation();
|
} catch {
|
||||||
const items: ItemType[] = [
|
return 'rep';
|
||||||
{
|
}
|
||||||
key: '/',
|
}
|
||||||
icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />,
|
|
||||||
label: 'Painel',
|
const REP_ITEMS: ItemType[] = [
|
||||||
},
|
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
||||||
{
|
|
||||||
key: '/agenda',
|
|
||||||
icon: <FontAwesomeIcon icon={faCalendarDays} fixedWidth />,
|
|
||||||
label: 'Agenda e Rotas',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '/clientes',
|
|
||||||
icon: <FontAwesomeIcon icon={faUsers} fixedWidth />,
|
|
||||||
label: 'Clientes',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '/catalogo',
|
|
||||||
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
|
||||||
label: 'Catálogo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: '/funil',
|
key: '/funil',
|
||||||
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
|
||||||
label: 'Funil de Vendas',
|
label: 'Funil de Vendas',
|
||||||
},
|
},
|
||||||
|
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
|
||||||
|
{
|
||||||
|
key: '/catalogo',
|
||||||
|
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
||||||
|
label: 'Catálogo',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '/pedidos',
|
key: '/pedidos',
|
||||||
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
||||||
label: 'Pedidos',
|
label: 'Pedidos',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/comissao',
|
key: '/pedidos-erp',
|
||||||
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
||||||
label: 'Comissão / FLEX',
|
label: 'Consulta ERP',
|
||||||
|
},
|
||||||
|
{ key: '/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
|
||||||
|
{
|
||||||
|
key: '/relatorios',
|
||||||
|
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
||||||
|
label: 'Relatórios',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUPERVISOR_ITEMS: ItemType[] = [
|
||||||
|
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
||||||
|
{
|
||||||
|
key: '/aprovacoes',
|
||||||
|
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
||||||
|
label: 'Aprovações',
|
||||||
|
},
|
||||||
|
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
|
||||||
|
{
|
||||||
|
key: '/pedidos',
|
||||||
|
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
||||||
|
label: 'Pedidos',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'divider',
|
key: '/catalogo',
|
||||||
|
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
||||||
|
label: 'Catálogo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/relatorios',
|
||||||
|
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
||||||
|
label: 'Relatórios',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const GERENTE_ITEMS: ItemType[] = [
|
||||||
|
{ key: '/ger', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
||||||
|
{ key: '/ger/equipe', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Equipe' },
|
||||||
|
{
|
||||||
|
key: '/clientes',
|
||||||
|
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
|
||||||
|
label: 'Clientes',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/relatorios',
|
key: '/relatorios',
|
||||||
@@ -66,11 +97,29 @@ export function Sidebar() {
|
|||||||
label: 'Relatórios',
|
label: 'Relatórios',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/configuracoes',
|
key: '/ger/politicas',
|
||||||
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faTags} fixedWidth />,
|
||||||
label: 'Configurações',
|
label: 'Políticas Comerciais',
|
||||||
},
|
},
|
||||||
];
|
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getItems(role: string): ItemType[] {
|
||||||
|
if (role === 'manager' || role === 'admin') return GERENTE_ITEMS;
|
||||||
|
if (role === 'supervisor') return SUPERVISOR_ITEMS;
|
||||||
|
return REP_ITEMS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const role = getRole();
|
||||||
|
const items = getItems(role);
|
||||||
|
|
||||||
|
const selectedKey = items.find((item) => {
|
||||||
|
const key = (item as { key?: string }).key ?? '';
|
||||||
|
return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
|
||||||
|
})?.key as string | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -88,7 +137,7 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
<Menu
|
<Menu
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[location.pathname]}
|
selectedKeys={selectedKey ? [selectedKey] : [location.pathname]}
|
||||||
items={items}
|
items={items}
|
||||||
onClick={({ key }) => navigate({ to: key as string })}
|
onClick={({ key }) => navigate({ to: key as string })}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
import { Avatar, Badge, Button, Dropdown, Flex, Input, Typography } from 'antd';
|
import { Avatar, Badge, Button, Dropdown, Flex, Grid, Typography } from 'antd';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import { faBell, faBuilding, faRightFromBracket } from '@fortawesome/free-solid-svg-icons';
|
||||||
faBell,
|
|
||||||
faMagnifyingGlass,
|
|
||||||
faBars,
|
|
||||||
faRightFromBracket,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { brandTokens } from '../../lib/theme';
|
import { brandTokens } from '../../lib/theme';
|
||||||
import { FoundationStatus } from './FoundationStatus';
|
import { FoundationStatus } from './FoundationStatus';
|
||||||
import { usePendingCount } from '../../lib/queries/notifications';
|
import { usePendingCount } from '../../lib/queries/notifications';
|
||||||
import { useCurrentUser } from '../../lib/queries/auth';
|
import { useCurrentUser } from '../../lib/queries/auth';
|
||||||
|
import { useCompany } from '../../lib/queries/company';
|
||||||
import { authStore } from '../../lib/auth-store';
|
import { authStore } from '../../lib/auth-store';
|
||||||
|
|
||||||
interface TopbarProps {
|
const { useBreakpoint } = Grid;
|
||||||
onToggleSidebar?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Topbar canônica do SAR (80px de altura — brand.md).
|
|
||||||
* Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita.
|
|
||||||
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
|
|
||||||
*/
|
|
||||||
function logout() {
|
function logout() {
|
||||||
authStore.clear();
|
authStore.clear();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Topbar({ onToggleSidebar }: TopbarProps) {
|
export function Topbar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const isDesktop = !!screens.lg;
|
||||||
const { data: pendingData } = usePendingCount();
|
const { data: pendingData } = usePendingCount();
|
||||||
const pendingCount = pendingData?.count ?? 0;
|
const pendingCount = pendingData?.count ?? 0;
|
||||||
const { data: user } = useCurrentUser();
|
const { data: user } = useCurrentUser();
|
||||||
|
const { data: company } = useCompany();
|
||||||
|
const nomeFantasia = company?.nomeFantasia?.trim() ?? null;
|
||||||
|
const razaoSocial = company?.razaoSocial?.trim() ?? null;
|
||||||
|
const nomeEmpresa = nomeFantasia ?? razaoSocial ?? '—';
|
||||||
|
const mostrarRazao = nomeFantasia && razaoSocial && nomeFantasia !== razaoSocial;
|
||||||
const initials = user?.nome
|
const initials = user?.nome
|
||||||
? user.nome
|
? user.nome
|
||||||
.split(' ')
|
.split(' ')
|
||||||
@@ -75,26 +71,20 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
|||||||
align="center"
|
align="center"
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
style={{
|
style={{
|
||||||
height: 'var(--layout-topbar-height)',
|
height: isDesktop ? 'var(--layout-topbar-height)' : 56,
|
||||||
padding: '0 var(--space-lg)',
|
padding: isDesktop ? '0 var(--space-lg)' : '0 var(--space-md)',
|
||||||
background: 'var(--bg-surface)',
|
background: 'var(--bg-surface)',
|
||||||
borderBottom: '1px solid var(--border-subtle)',
|
borderBottom: '1px solid var(--border-subtle)',
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
zIndex: 'var(--z-sticky)',
|
zIndex: 'var(--z-sticky)',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Lado esquerdo: hamburger (mobile) + logo */}
|
{/* Logo */}
|
||||||
<Flex align="center" gap={16}>
|
<Flex align="center" gap={isDesktop ? 12 : 8}>
|
||||||
<Button
|
<img src="/sar-icon.png" alt="SAR" style={{ height: isDesktop ? 40 : 32, width: 'auto' }} />
|
||||||
type="text"
|
{isDesktop && (
|
||||||
icon={<FontAwesomeIcon icon={faBars} />}
|
|
||||||
onClick={onToggleSidebar}
|
|
||||||
aria-label="Alternar menu"
|
|
||||||
style={{ display: 'inline-flex' }}
|
|
||||||
/>
|
|
||||||
<Flex align="center" gap={12}>
|
|
||||||
<img src="/sar-icon.png" alt="SAR" style={{ height: 40, width: 'auto' }} />
|
|
||||||
<Flex vertical gap={0}>
|
<Flex vertical gap={0}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -118,24 +108,54 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
|||||||
Força de Vendas
|
Força de Vendas
|
||||||
</span>
|
</span>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Centro: search (Supervisor/Admin) */}
|
{/* Centro: empresa ativa */}
|
||||||
<Flex flex={1} justify="center" style={{ maxWidth: 480, margin: '0 var(--space-2xl)' }}>
|
<Flex
|
||||||
<Input
|
flex={1}
|
||||||
size="large"
|
justify="center"
|
||||||
placeholder="Buscar cliente, pedido, produto..."
|
style={{
|
||||||
prefix={
|
margin: `0 ${isDesktop ? 'var(--space-2xl)' : 'var(--space-sm)'}`,
|
||||||
<FontAwesomeIcon icon={faMagnifyingGlass} style={{ color: 'var(--text-muted)' }} />
|
overflow: 'hidden',
|
||||||
}
|
}}
|
||||||
style={{ borderRadius: 12 }}
|
>
|
||||||
aria-label="Buscar"
|
<Flex align="center" gap={isDesktop ? 10 : 6} style={{ overflow: 'hidden' }}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faBuilding}
|
||||||
|
style={{ color: 'var(--text-muted)', fontSize: isDesktop ? 16 : 13, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
|
<Flex vertical gap={0} style={{ overflow: 'hidden' }}>
|
||||||
|
<Typography.Text
|
||||||
|
ellipsis
|
||||||
|
style={{
|
||||||
|
fontSize: isDesktop ? 'var(--text-xl)' : 'var(--text-sm)',
|
||||||
|
fontWeight: 'var(--font-weight-semibold)',
|
||||||
|
color: 'var(--text-main)',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nomeEmpresa}
|
||||||
|
</Typography.Text>
|
||||||
|
{isDesktop && mostrarRazao && (
|
||||||
|
<Typography.Text
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-2xs)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{razaoSocial}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Lado direito: novo pedido + status fundação + notificações + perfil */}
|
{/* Lado direito */}
|
||||||
<Flex align="center" gap={16}>
|
<Flex align="center" gap={isDesktop ? 16 : 8}>
|
||||||
|
{isDesktop && (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
@@ -144,23 +164,25 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
|||||||
>
|
>
|
||||||
Novo Pedido
|
Novo Pedido
|
||||||
</Button>
|
</Button>
|
||||||
<FoundationStatus />
|
)}
|
||||||
|
{isDesktop && <FoundationStatus />}
|
||||||
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
|
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="large"
|
size={isDesktop ? 'large' : 'middle'}
|
||||||
icon={<FontAwesomeIcon icon={faBell} />}
|
icon={<FontAwesomeIcon icon={faBell} />}
|
||||||
aria-label="Notificações"
|
aria-label="Notificações"
|
||||||
/>
|
/>
|
||||||
</Badge>
|
</Badge>
|
||||||
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
|
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={40}
|
size={isDesktop ? 40 : 32}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--jcs-blue-light)',
|
background: 'var(--jcs-blue-light)',
|
||||||
color: 'var(--jcs-blue)',
|
color: 'var(--jcs-blue)',
|
||||||
fontWeight: 'var(--font-weight-semibold)',
|
fontWeight: 'var(--font-weight-semibold)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
|
|||||||
64
apps/web/src/lib/condicoes-comerciais.ts
Normal file
64
apps/web/src/lib/condicoes-comerciais.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
|
||||||
|
import { usePromocoesVigentes, useRegrasVigentes } from './queries/politicas';
|
||||||
|
|
||||||
|
// Condição comercial vigente que alcança um produto: promoção (por produto ou
|
||||||
|
// grupo) ou regra de desconto (por grupo/subgrupo/rep). Usada para sinalizar no
|
||||||
|
// catálogo do rep que o produto tem proposta diferenciada e detalhar qual é.
|
||||||
|
|
||||||
|
export interface CondicaoComercial {
|
||||||
|
tipo: 'promocao' | 'regra';
|
||||||
|
descricao: string;
|
||||||
|
descPct: number;
|
||||||
|
dataFim: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProdutoRef {
|
||||||
|
codigo: string;
|
||||||
|
codGrupo: number | null;
|
||||||
|
codSubgrupo: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function condicoesDoProduto(
|
||||||
|
p: ProdutoRef,
|
||||||
|
promocoes: PromocaoVigente[] | undefined,
|
||||||
|
regras: RegraVigente[] | undefined,
|
||||||
|
): CondicaoComercial[] {
|
||||||
|
const conds: CondicaoComercial[] = [];
|
||||||
|
|
||||||
|
for (const promo of promocoes ?? []) {
|
||||||
|
const porProduto = promo.codProduto && promo.codProduto.trim() === p.codigo.trim();
|
||||||
|
const porGrupo =
|
||||||
|
promo.grpProd && p.codGrupo != null && promo.grpProd.trim() === String(p.codGrupo);
|
||||||
|
if (porProduto || porGrupo) {
|
||||||
|
conds.push({
|
||||||
|
tipo: 'promocao',
|
||||||
|
descricao: promo.descricao,
|
||||||
|
descPct: promo.descPct,
|
||||||
|
dataFim: promo.dataFim,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const regra of regras ?? []) {
|
||||||
|
const grupoOk = regra.codGrupo == null || (p.codGrupo != null && regra.codGrupo === p.codGrupo);
|
||||||
|
const subgrupoOk =
|
||||||
|
regra.codSubgrupo == null || (p.codSubgrupo != null && regra.codSubgrupo === p.codSubgrupo);
|
||||||
|
if (grupoOk && subgrupoOk) {
|
||||||
|
conds.push({
|
||||||
|
tipo: 'regra',
|
||||||
|
descricao: regra.descricao,
|
||||||
|
descPct: regra.descPct,
|
||||||
|
dataFim: regra.dataFim,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conds.sort((a, b) => b.descPct - a.descPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook: função de lookup por produto, com promoções e regras vigentes em cache
|
||||||
|
export function useCondicoesComerciais(): (p: ProdutoRef) => CondicaoComercial[] {
|
||||||
|
const { data: promos } = usePromocoesVigentes();
|
||||||
|
const { data: regras } = useRegrasVigentes();
|
||||||
|
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras);
|
||||||
|
}
|
||||||
15
apps/web/src/lib/feedback.ts
Normal file
15
apps/web/src/lib/feedback.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MessageInstance } from 'antd/es/message/interface';
|
||||||
|
|
||||||
|
// Instância do message obtida via App.useApp() (herda tema/contexto), registrada
|
||||||
|
// pelo AppShell no mount. Fora do React (ex.: caches do TanStack Query) usamos
|
||||||
|
// esta referência em vez do message estático do AntD.
|
||||||
|
let messageApi: MessageInstance | null = null;
|
||||||
|
|
||||||
|
export function registerMessageApi(api: MessageInstance): void {
|
||||||
|
messageApi = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
// key fixa colapsa erros repetidos (várias queries falhando juntas) num toast só.
|
||||||
|
export function notifyError(content: string, key?: string): void {
|
||||||
|
messageApi?.error({ content, key, duration: 5 });
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
FormaPagamentoSchema,
|
FormaPagamentoSchema,
|
||||||
|
MunicipioSchema,
|
||||||
PautaSchema,
|
PautaSchema,
|
||||||
ProdutoListResponseSchema,
|
ProdutoListResponseSchema,
|
||||||
ProdutoDetailSchema,
|
ProdutoDetailSchema,
|
||||||
type FormaPagamento,
|
type FormaPagamento,
|
||||||
|
type Municipio,
|
||||||
type ProdutoListQuery,
|
type ProdutoListQuery,
|
||||||
type ProdutoListResponse,
|
type ProdutoListResponse,
|
||||||
type ProdutoDetail,
|
type ProdutoDetail,
|
||||||
@@ -13,6 +15,19 @@ import {
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
export function useMunicipios(q?: string) {
|
||||||
|
return useQuery<Municipio[]>({
|
||||||
|
queryKey: ['catalog', 'municipios', q ?? ''],
|
||||||
|
queryFn: async () => {
|
||||||
|
const qs = q ? `?q=${encodeURIComponent(q)}` : '';
|
||||||
|
const res = await apiFetch(`/catalog/municipios${qs}`);
|
||||||
|
return z.array(MunicipioSchema).parse(res);
|
||||||
|
},
|
||||||
|
staleTime: 24 * 60 * 60 * 1000,
|
||||||
|
enabled: !q || q.length >= 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function usePautas() {
|
export function usePautas() {
|
||||||
return useQuery<Pauta[]>({
|
return useQuery<Pauta[]>({
|
||||||
queryKey: ['catalog', 'pautas'],
|
queryKey: ['catalog', 'pautas'],
|
||||||
|
|||||||
@@ -1,11 +1,30 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ClientListResponseSchema,
|
|
||||||
ClientDetailSchema,
|
ClientDetailSchema,
|
||||||
|
ClientListResponseSchema,
|
||||||
|
ClientNovoResultSchema,
|
||||||
|
ContatoResultSchema,
|
||||||
|
ContatoSchema,
|
||||||
|
CtrSummarySchema,
|
||||||
|
CtrTituloSchema,
|
||||||
|
NotaFiscalSchema,
|
||||||
|
PedidoSummarySchema,
|
||||||
|
TopProdutoSchema,
|
||||||
|
type ClientDetail,
|
||||||
type ClientListQuery,
|
type ClientListQuery,
|
||||||
type ClientListResponse,
|
type ClientListResponse,
|
||||||
type ClientDetail,
|
type ClientNovoResult,
|
||||||
|
type Contato,
|
||||||
|
type ContatoResult,
|
||||||
|
type CtrSummary,
|
||||||
|
type CtrTitulo,
|
||||||
|
type CreateClientNovo,
|
||||||
|
type CreateContato,
|
||||||
|
type NotaFiscal,
|
||||||
|
type PedidoSummary,
|
||||||
|
type TopProduto,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
|
import { z } from 'zod';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
export const CLIENT_KEYS = {
|
export const CLIENT_KEYS = {
|
||||||
@@ -42,3 +61,101 @@ export function useClientDetail(id: number | string | undefined) {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return useQuery<Contato[]>({
|
||||||
|
queryKey: ['clients', 'contacts', idCliente],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/clients/${idCliente}/contacts`);
|
||||||
|
return z.array(ContatoSchema).parse(res);
|
||||||
|
},
|
||||||
|
enabled: !!idCliente,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateContato(idCliente: number | undefined) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<ContatoResult, Error, CreateContato>({
|
||||||
|
mutationFn: async (data) => {
|
||||||
|
const res = await apiFetch(`/clients/${idCliente}/contacts`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
return ContatoResultSchema.parse(res);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['clients', 'contacts', idCliente] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateClientNovo() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<ClientNovoResult, Error, CreateClientNovo>({
|
||||||
|
mutationFn: async (data) => {
|
||||||
|
const res = await apiFetch('/clients/novo', {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
return ClientNovoResultSchema.parse(res);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: CLIENT_KEYS.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
44
apps/web/src/lib/queries/funil.ts
Normal file
44
apps/web/src/lib/queries/funil.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
FunilResponseSchema,
|
||||||
|
OportunidadeSchema,
|
||||||
|
type CreateOportunidadeDto,
|
||||||
|
type UpdateOportunidadeDto,
|
||||||
|
type Oportunidade,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
export function useFunil() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['funil'],
|
||||||
|
queryFn: async () => FunilResponseSchema.parse(await apiFetch('/funil')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateOportunidade() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: CreateOportunidadeDto): Promise<Oportunidade> =>
|
||||||
|
apiFetch('/funil', { method: 'POST', body: dto }).then((r) => OportunidadeSchema.parse(r)),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateOportunidade() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, dto }: { id: number; dto: UpdateOportunidadeDto }): Promise<Oportunidade> =>
|
||||||
|
apiFetch(`/funil/${id}`, { method: 'PATCH', body: dto }).then((r) =>
|
||||||
|
OportunidadeSchema.parse(r),
|
||||||
|
),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteOportunidade() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number): Promise<void> => apiFetch(`/funil/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
171
apps/web/src/lib/queries/gerente.ts
Normal file
171
apps/web/src/lib/queries/gerente.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ManagerDashboardSchema,
|
||||||
|
EquipeResponseSchema,
|
||||||
|
AlcadaDescontosResponseSchema,
|
||||||
|
PromocoesResponseSchema,
|
||||||
|
RegrasDescontoResponseSchema,
|
||||||
|
GruposProdutoResponseSchema,
|
||||||
|
type ManagerDashboard,
|
||||||
|
type EquipeResponse,
|
||||||
|
type AlcadaDescontosResponse,
|
||||||
|
type PromocoesResponse,
|
||||||
|
type UpsertDescontoBody,
|
||||||
|
type CreatePromocaoBody,
|
||||||
|
type UpdatePromocaoBody,
|
||||||
|
type RegrasDescontoResponse,
|
||||||
|
type GruposProdutoResponse,
|
||||||
|
type CreateRegraDescontoBody,
|
||||||
|
type UpdateRegraDescontoBody,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
export function useManagerDashboard(mes?: number, ano?: number) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (mes) params.set('mes', String(mes));
|
||||||
|
if (ano) params.set('ano', String(ano));
|
||||||
|
const qs = params.size > 0 ? `?${params.toString()}` : '';
|
||||||
|
return useQuery<ManagerDashboard>({
|
||||||
|
queryKey: ['dashboard', 'manager', mes, ano],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch(`/dashboard/manager${qs}`);
|
||||||
|
return ManagerDashboardSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEquipe() {
|
||||||
|
return useQuery<EquipeResponse>({
|
||||||
|
queryKey: ['equipe'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/equipe');
|
||||||
|
return EquipeResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePoliticasDescontos() {
|
||||||
|
return useQuery<AlcadaDescontosResponse>({
|
||||||
|
queryKey: ['politicas', 'descontos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/descontos');
|
||||||
|
return AlcadaDescontosResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePromocoes() {
|
||||||
|
return useQuery<PromocoesResponse>({
|
||||||
|
queryKey: ['politicas', 'promocoes'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/promocoes');
|
||||||
|
return PromocoesResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertDesconto() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: UpsertDescontoBody) =>
|
||||||
|
apiFetch('/politicas/descontos', { method: 'POST', body }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePromocao() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: CreatePromocaoBody) =>
|
||||||
|
apiFetch('/politicas/promocoes', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePromocao() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) =>
|
||||||
|
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePromocao() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => apiFetch(`/politicas/promocoes/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegrasDesconto() {
|
||||||
|
return useQuery<RegrasDescontoResponse>({
|
||||||
|
queryKey: ['politicas', 'regras'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/regras');
|
||||||
|
return RegrasDescontoResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGruposProduto() {
|
||||||
|
return useQuery<GruposProdutoResponse>({
|
||||||
|
queryKey: ['politicas', 'grupos-produto'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/grupos-produto');
|
||||||
|
return GruposProdutoResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 30 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRegraDesconto() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: CreateRegraDescontoBody) =>
|
||||||
|
apiFetch('/politicas/regras', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRegraDesconto() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: UpdateRegraDescontoBody }) =>
|
||||||
|
apiFetch(`/politicas/regras/${id}`, { method: 'PATCH', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRegraDesconto() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => apiFetch(`/politicas/regras/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
PedidoErpConsultaResponseSchema,
|
||||||
PedidoListResponseSchema,
|
PedidoListResponseSchema,
|
||||||
PedidoDetailSchema,
|
PedidoDetailSchema,
|
||||||
|
type PedidoErpConsultaQuery,
|
||||||
|
type PedidoErpConsultaResponse,
|
||||||
type PedidoListQuery,
|
type PedidoListQuery,
|
||||||
type PedidoListResponse,
|
type PedidoListResponse,
|
||||||
type PedidoDetail,
|
type PedidoDetail,
|
||||||
@@ -34,7 +37,11 @@ export function useOrderDetail(id: string | undefined) {
|
|||||||
queryKey: ['orders', id],
|
queryKey: ['orders', id],
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiFetch(`/orders/${id}`);
|
// Pedidos ERP têm id 'erp-{idPedido}' — endpoint separado sem ParseUUIDPipe
|
||||||
|
const path = id?.startsWith('erp-')
|
||||||
|
? `/orders/erp/${id.replace('erp-', '')}`
|
||||||
|
: `/orders/${id}`;
|
||||||
|
const res = await apiFetch(path);
|
||||||
return PedidoDetailSchema.parse(res);
|
return PedidoDetailSchema.parse(res);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -51,3 +58,22 @@ export function useClientOrders(idCliente: number | undefined) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}, enabled = true) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.search) search.set('search', params.search);
|
||||||
|
if (params.from) search.set('from', params.from);
|
||||||
|
if (params.to) search.set('to', params.to);
|
||||||
|
if (params.page) search.set('page', String(params.page));
|
||||||
|
if (params.limit) search.set('limit', String(params.limit));
|
||||||
|
|
||||||
|
const qs = search.toString();
|
||||||
|
return useQuery<PedidoErpConsultaResponse>({
|
||||||
|
queryKey: ['orders-erp-consulta', params],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`);
|
||||||
|
return PedidoErpConsultaResponseSchema.parse(res);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
33
apps/web/src/lib/queries/politicas.ts
Normal file
33
apps/web/src/lib/queries/politicas.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
RegrasVigentesResponseSchema,
|
||||||
|
PromocoesVigentesResponseSchema,
|
||||||
|
type RegrasVigentesResponse,
|
||||||
|
type PromocoesVigentesResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
// Regras de desconto vigentes para o usuário logado (rep recebe só as suas).
|
||||||
|
// Usadas na montagem do pedido para pré-aplicar o desconto liberado pelo gerente.
|
||||||
|
export function useRegrasVigentes() {
|
||||||
|
return useQuery<RegrasVigentesResponse>({
|
||||||
|
queryKey: ['politicas', 'regras-vigentes'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/regras/vigentes');
|
||||||
|
return RegrasVigentesResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
|
||||||
|
export function usePromocoesVigentes() {
|
||||||
|
return useQuery<PromocoesVigentesResponse>({
|
||||||
|
queryKey: ['politicas', 'promocoes-vigentes'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/promocoes/vigentes');
|
||||||
|
return PromocoesVigentesResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
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}`)),
|
||||||
|
});
|
||||||
|
}
|
||||||
102
apps/web/src/lib/queries/sac.ts
Normal file
102
apps/web/src/lib/queries/sac.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ChamadoListResponseSchema,
|
||||||
|
ChamadoSchema,
|
||||||
|
type CreateChamadoDto,
|
||||||
|
type AddMensagemDto,
|
||||||
|
type ResolverChamadoDto,
|
||||||
|
type Chamado,
|
||||||
|
type ChamadoListResponse,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
export function useChamados() {
|
||||||
|
return useQuery<ChamadoListResponse>({
|
||||||
|
queryKey: ['chamados'],
|
||||||
|
queryFn: async () => ChamadoListResponseSchema.parse(await apiFetch('/chamados')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChamado(id: number) {
|
||||||
|
return useQuery<Chamado>({
|
||||||
|
queryKey: ['chamados', id],
|
||||||
|
queryFn: async () => ChamadoSchema.parse(await apiFetch(`/chamados/${id}`)),
|
||||||
|
enabled: id > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateChamado() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: CreateChamadoDto): Promise<Chamado> =>
|
||||||
|
apiFetch('/chamados', { method: 'POST', body: dto }).then((r) => ChamadoSchema.parse(r)),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddMensagemRep(id: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
||||||
|
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
|
||||||
|
ChamadoSchema.parse(r),
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['chamados', id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['chamados'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCancelChamado() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number): Promise<Chamado> =>
|
||||||
|
apiFetch(`/chamados/${id}/cancelar`, { method: 'PATCH' }).then((r) => ChamadoSchema.parse(r)),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerente
|
||||||
|
export function useChamadosGer() {
|
||||||
|
return useQuery<ChamadoListResponse>({
|
||||||
|
queryKey: ['ger', 'chamados'],
|
||||||
|
queryFn: async () => ChamadoListResponseSchema.parse(await apiFetch('/ger/chamados')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChamadoGer(id: number) {
|
||||||
|
return useQuery<Chamado>({
|
||||||
|
queryKey: ['ger', 'chamados', id],
|
||||||
|
queryFn: async () => ChamadoSchema.parse(await apiFetch(`/ger/chamados/${id}`)),
|
||||||
|
enabled: id > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddMensagemEmpresa(id: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
||||||
|
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
|
||||||
|
ChamadoSchema.parse(r),
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['ger', 'chamados'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResolverChamado(id: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: ResolverChamadoDto): Promise<Chamado> =>
|
||||||
|
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: dto }).then((r) =>
|
||||||
|
ChamadoSchema.parse(r),
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['ger', 'chamados'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,11 +1,34 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query';
|
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
|
||||||
|
import { ApiError } from './api-client';
|
||||||
|
import { notifyError } from './feedback';
|
||||||
|
|
||||||
|
function describeError(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return error.problem.detail ?? error.problem.title ?? 'Erro no servidor';
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message) return error.message;
|
||||||
|
return 'Erro inesperado';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueryClient canônico do SAR.
|
* QueryClient canônico do SAR.
|
||||||
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
|
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
|
||||||
* stale-while-revalidate para tempo real bater no Socket.IO, não em polling.
|
* stale-while-revalidate para tempo real bater no Socket.IO, não em polling.
|
||||||
|
* Erros nunca são silenciosos: falha de query/mutation sem tratamento local
|
||||||
|
* vira toast — Sandra/Daniel jamais devem "achar que salvou".
|
||||||
*/
|
*/
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error) => {
|
||||||
|
notifyError(`Falha ao carregar dados: ${describeError(error)}`, 'query-error');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (error, _variables, _context, mutation) => {
|
||||||
|
if (mutation.options.onError) return; // a tela já dá feedback próprio
|
||||||
|
notifyError(describeError(error), 'mutation-error');
|
||||||
|
},
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos
|
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
notFound,
|
notFound,
|
||||||
} from '@tanstack/react-router';
|
} from '@tanstack/react-router';
|
||||||
import { Typography } from 'antd';
|
import { Button, Result, Typography } from 'antd';
|
||||||
import { AppShell } from '../components/layout/AppShell';
|
import { AppShell } from '../components/layout/AppShell';
|
||||||
import { RepPainel } from '../cockpits/rep/RepPainel';
|
import { RepPainel } from '../cockpits/rep/RepPainel';
|
||||||
import { ClientsPage } from '../cockpits/rep/ClientsPage';
|
import { ClientsPage } from '../cockpits/rep/ClientsPage';
|
||||||
@@ -13,10 +13,20 @@ import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage';
|
|||||||
import { OrdersPage } from '../cockpits/rep/OrdersPage';
|
import { OrdersPage } from '../cockpits/rep/OrdersPage';
|
||||||
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
|
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
|
||||||
import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
|
import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
|
||||||
|
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 { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
||||||
|
import { CarteirePage } from '../cockpits/rep/CarteirePage';
|
||||||
|
import { FunilPage } from '../cockpits/rep/FunilPage';
|
||||||
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 { GerPainel } from '../cockpits/ger/GerPainel';
|
||||||
|
import { EquipePage } from '../cockpits/ger/EquipePage';
|
||||||
|
import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
|
||||||
|
import { ChamadosPage } from '../cockpits/rep/ChamadosPage';
|
||||||
|
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
|
||||||
import { authStore } from './auth-store';
|
import { authStore } from './auth-store';
|
||||||
|
|
||||||
function getRoleFromToken(): string {
|
function getRoleFromToken(): string {
|
||||||
@@ -32,7 +42,26 @@ function getRoleFromToken(): string {
|
|||||||
|
|
||||||
function HomeRoute() {
|
function HomeRoute() {
|
||||||
const role = getRoleFromToken();
|
const role = getRoleFromToken();
|
||||||
return role === 'supervisor' || role === 'manager' ? <SupervisorPainel /> : <RepPainel />;
|
if (role === 'manager' || role === 'admin') return <GerPainel />;
|
||||||
|
if (role === 'supervisor') return <SupervisorPainel />;
|
||||||
|
return <RepPainel />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error boundary por rota: uma exceção de render (ex.: ZodError de payload
|
||||||
|
// inesperado) mostra esta tela em vez de derrubar o app inteiro em branco.
|
||||||
|
function RouteErrorPage({ error }: { error: Error }) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="Algo deu errado"
|
||||||
|
subTitle={error.message || 'Erro inesperado ao exibir esta tela.'}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
Recarregar
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotFoundPage() {
|
function NotFoundPage() {
|
||||||
@@ -55,6 +84,7 @@ const rootRoute = createRootRoute({
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
),
|
),
|
||||||
notFoundComponent: NotFoundPage,
|
notFoundComponent: NotFoundPage,
|
||||||
|
errorComponent: RouteErrorPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
@@ -75,6 +105,12 @@ const clientesRoute = createRoute({
|
|||||||
component: ClientsPage,
|
component: ClientsPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const novoClienteRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/clientes/novo',
|
||||||
|
component: NewClientPage,
|
||||||
|
});
|
||||||
|
|
||||||
const clienteDetailRoute = createRoute({
|
const clienteDetailRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/clientes/$id',
|
path: '/clientes/$id',
|
||||||
@@ -111,29 +147,94 @@ const catalogoRoute = createRoute({
|
|||||||
component: CatalogPage,
|
component: CatalogPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pedidosErpRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/pedidos-erp',
|
||||||
|
component: OrdersErpPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const relatoriosRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/relatorios',
|
||||||
|
component: RelatoriosPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const carteiraRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/clientes/carteira',
|
||||||
|
component: CarteirePage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const funilRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/funil',
|
||||||
|
component: FunilPage,
|
||||||
|
});
|
||||||
|
|
||||||
const aprovacoes = createRoute({
|
const aprovacoes = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/aprovacoes',
|
path: '/aprovacoes',
|
||||||
component: ApprovalQueuePage,
|
component: ApprovalQueuePage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gerPainelRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/ger',
|
||||||
|
component: GerPainel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const equipeRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/ger/equipe',
|
||||||
|
component: EquipePage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const politicasRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/ger/politicas',
|
||||||
|
component: PoliticasPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const chamadosRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/chamados',
|
||||||
|
component: ChamadosPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gerChamadosRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/ger/chamados',
|
||||||
|
component: GerChamadosPage,
|
||||||
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
rafaelRoute,
|
rafaelRoute,
|
||||||
clientesRoute,
|
clientesRoute,
|
||||||
|
novoClienteRoute,
|
||||||
clienteDetailRoute,
|
clienteDetailRoute,
|
||||||
pedidosRoute,
|
pedidosRoute,
|
||||||
novoOrderRoute,
|
novoOrderRoute,
|
||||||
pedidoDetailRoute,
|
pedidoDetailRoute,
|
||||||
pedidoPrintRoute,
|
pedidoPrintRoute,
|
||||||
catalogoRoute,
|
catalogoRoute,
|
||||||
|
pedidosErpRoute,
|
||||||
|
relatoriosRoute,
|
||||||
|
carteiraRoute,
|
||||||
|
funilRoute,
|
||||||
aprovacoes,
|
aprovacoes,
|
||||||
|
gerPainelRoute,
|
||||||
|
equipeRoute,
|
||||||
|
politicasRoute,
|
||||||
|
chamadosRoute,
|
||||||
|
gerChamadosRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
|
defaultErrorComponent: RouteErrorPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export default defineConfig(() => ({
|
|||||||
cacheDir: '../../node_modules/.vite/apps/web',
|
cacheDir: '../../node_modules/.vite/apps/web',
|
||||||
server: {
|
server: {
|
||||||
port: 4200,
|
port: 4200,
|
||||||
host: 'localhost',
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: true,
|
||||||
// Proxy /api/* → API Nest em :3000 (default API_PORT).
|
// Proxy /api/* → API Nest em :3000 (default API_PORT).
|
||||||
// Evita CORS em dev e mantém URL relativa no código da Web — em produção,
|
// Evita CORS em dev e mantém URL relativa no código da Web — em produção,
|
||||||
// mesmo origin via Nginx (/api/* → backend).
|
// mesmo origin via Nginx (/api/* → backend).
|
||||||
@@ -22,7 +23,7 @@ export default defineConfig(() => ({
|
|||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
port: 4200,
|
port: 4200,
|
||||||
host: 'localhost',
|
host: '0.0.0.0',
|
||||||
},
|
},
|
||||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
// Uncomment this if you are using workers.
|
// Uncomment this if you are using workers.
|
||||||
@@ -43,6 +44,7 @@ export default defineConfig(() => ({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
passWithNoTests: true,
|
||||||
reporters: ['default'],
|
reporters: ['default'],
|
||||||
coverage: {
|
coverage: {
|
||||||
reportsDirectory: '../../coverage/apps/web',
|
reportsDirectory: '../../coverage/apps/web',
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ services:
|
|||||||
# subdir por major-version pra suportar pg_upgrade --link sem
|
# subdir por major-version pra suportar pg_upgrade --link sem
|
||||||
# boundary issues. Ref: docker-library/postgres#1259.
|
# boundary issues. Ref: docker-library/postgres#1259.
|
||||||
- sar-postgres-data:/var/lib/postgresql
|
- sar-postgres-data:/var/lib/postgresql
|
||||||
- ./scripts/postgres-init:/docker-entrypoint-initdb.d:ro
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U sar -d sar_master']
|
test: ['CMD-SHELL', 'pg_isready -U sar -d sar_master']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
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,8 @@ 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';
|
||||||
|
export * from './lib/politicas.contract';
|
||||||
|
export * from './lib/equipe.contract';
|
||||||
|
export * from './lib/funil.contract';
|
||||||
|
export * from './lib/sac.contract';
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const JwtRoleSchema = z.enum(['rep', 'supervisor', 'manager', 'admin']);
|
|||||||
|
|
||||||
export const DevTokenRequestSchema = z.object({
|
export const DevTokenRequestSchema = z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
idEmpresa: z.coerce.number().int().positive(),
|
// Opcional: quando ausente, o backend usa DEV_EMPRESA_ID do .env
|
||||||
|
idEmpresa: z.coerce.number().int().positive().optional(),
|
||||||
role: JwtRoleSchema,
|
role: JwtRoleSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -62,3 +62,148 @@ export const ClientListResponseSchema = z.object({
|
|||||||
limit: z.number().int().positive(),
|
limit: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
export type ClientListResponse = z.infer<typeof ClientListResponseSchema>;
|
export type ClientListResponse = z.infer<typeof ClientListResponseSchema>;
|
||||||
|
|
||||||
|
// ─── Contato ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ContatoSchema = z.object({
|
||||||
|
idContato: z.number().int(),
|
||||||
|
idEmpresa: z.number().int(),
|
||||||
|
idCorrent: z.number().int(),
|
||||||
|
idChatwoot: z.number().int().nullable(),
|
||||||
|
nome: z.string(),
|
||||||
|
empresa: z.string().nullable(),
|
||||||
|
cargo: z.string().nullable(),
|
||||||
|
departamento: z.string().nullable(),
|
||||||
|
dtAniversario: z.string().nullable(),
|
||||||
|
telefone: z.string().nullable(),
|
||||||
|
ramal: z.string().nullable(),
|
||||||
|
celular: z.string().nullable(),
|
||||||
|
whatsapp: z.string().nullable(),
|
||||||
|
email: z.string().nullable(),
|
||||||
|
ativo: z.number().int(),
|
||||||
|
anotacoes: z.string().nullable(),
|
||||||
|
nomeCliente: z.string().nullable(),
|
||||||
|
razaoCliente: z.string().nullable(),
|
||||||
|
codVendedor: z.number().int().nullable(),
|
||||||
|
});
|
||||||
|
export type Contato = z.infer<typeof ContatoSchema>;
|
||||||
|
|
||||||
|
export const CreateContatoSchema = z.object({
|
||||||
|
nome: z.string().min(1).max(100),
|
||||||
|
empresa: z.string().max(100).optional(),
|
||||||
|
cargo: z.string().max(100).optional(),
|
||||||
|
departamento: z.string().max(100).optional(),
|
||||||
|
dtAniversario: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD')
|
||||||
|
.optional(),
|
||||||
|
telefone: z.string().max(20).optional(),
|
||||||
|
ramal: z.string().max(10).optional(),
|
||||||
|
celular: z.string().max(20).optional(),
|
||||||
|
whatsapp: z.string().max(20).optional(),
|
||||||
|
email: z.string().max(150).optional(),
|
||||||
|
anotacoes: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type CreateContato = z.infer<typeof CreateContatoSchema>;
|
||||||
|
|
||||||
|
export const ContatoResultSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
idContatoErp: z.number().int().nullable(),
|
||||||
|
sincronizado: z.boolean(),
|
||||||
|
erroSync: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type ContatoResult = z.infer<typeof ContatoResultSchema>;
|
||||||
|
|
||||||
|
// ─── Produtos mais comprados por cliente ─────────────────────────────────────
|
||||||
|
|
||||||
|
export const TopProdutoSchema = z.object({
|
||||||
|
idProduto: z.number().int(),
|
||||||
|
codProduto: z.string(),
|
||||||
|
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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const MunicipioSchema = z.object({
|
||||||
|
idMunicipio: z.number().int(),
|
||||||
|
nome: z.string(),
|
||||||
|
uf: z.string(),
|
||||||
|
codigoIbge: z.number().int().nullable(),
|
||||||
|
});
|
||||||
|
export type Municipio = z.infer<typeof MunicipioSchema>;
|
||||||
|
|
||||||
|
// ─── Criar Cliente Novo ───────────────────────────────────────────────────────
|
||||||
|
// pesso: 0=PJ (CNPJ), 1=PF (CPF) — campo do ERP, não renomear
|
||||||
|
// indicadorIe: 1=contribuinte, 2=isento, 9=não contribuinte
|
||||||
|
|
||||||
|
export const CreateClientNovoSchema = z.object({
|
||||||
|
pesso: z.union([z.literal(0), z.literal(1)]),
|
||||||
|
consfinal: z.union([z.literal(0), z.literal(1)]).default(0),
|
||||||
|
nome: z.string().min(1).max(40),
|
||||||
|
razao: z.string().min(1).max(65),
|
||||||
|
cgcpf: z.string().max(18).optional(),
|
||||||
|
sufCgcpf: z.string().max(3).default(''),
|
||||||
|
inscr: z.string().max(18).default(''),
|
||||||
|
indicadorIe: z.union([z.literal(1), z.literal(2), z.literal(9)]).optional(),
|
||||||
|
endereco: z.string().max(60).default(''),
|
||||||
|
numEndereco: z.string().max(10).default(''),
|
||||||
|
bairro: z.string().max(60).default(''),
|
||||||
|
idMunicipio: z.number().int().positive(),
|
||||||
|
cep: z.string().max(9).default(''),
|
||||||
|
ddd: z.string().max(4).default(''),
|
||||||
|
telefone: z.string().max(35).default(''),
|
||||||
|
email: z.string().max(80).default(''),
|
||||||
|
limiteCredito: z.number().nonnegative().default(0),
|
||||||
|
codFormapag: z.number().int().optional(),
|
||||||
|
codPauta: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
export type CreateClientNovo = z.infer<typeof CreateClientNovoSchema>;
|
||||||
|
|
||||||
|
export const ClientNovoResultSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
idCorrentErp: z.number().int().nullable(),
|
||||||
|
sincronizado: z.boolean(),
|
||||||
|
erroSync: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type ClientNovoResult = z.infer<typeof ClientNovoResultSchema>;
|
||||||
|
|||||||
@@ -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,11 +34,19 @@ 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>;
|
||||||
|
|
||||||
|
export const MesAnoSchema = z.object({
|
||||||
|
mes: z.number().int().min(1).max(12),
|
||||||
|
valor: z.number(),
|
||||||
|
meta: z.number(),
|
||||||
|
atingiu: z.boolean(),
|
||||||
|
});
|
||||||
|
export type MesAno = z.infer<typeof MesAnoSchema>;
|
||||||
|
|
||||||
export const RepDashboardSchema = z.object({
|
export const RepDashboardSchema = z.object({
|
||||||
meta: z.object({
|
meta: z.object({
|
||||||
atingido: z.number(),
|
atingido: z.number(),
|
||||||
@@ -43,17 +54,13 @@ 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(),
|
||||||
|
historicoMensal: z.array(MesAnoSchema).default([]),
|
||||||
syncedAt: z.iso.datetime(),
|
syncedAt: z.iso.datetime(),
|
||||||
});
|
});
|
||||||
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
||||||
@@ -77,3 +84,44 @@ export const SupervisorDashboardSchema = z.object({
|
|||||||
syncedAt: z.iso.datetime(),
|
syncedAt: z.iso.datetime(),
|
||||||
});
|
});
|
||||||
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
|
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
|
||||||
|
|
||||||
|
export const PositivacaoRepSchema = z.object({
|
||||||
|
codVendedor: z.number().int(),
|
||||||
|
nomeVendedor: z.string().nullable(),
|
||||||
|
totalClientes: z.number().int(),
|
||||||
|
clientesPositivados: z.number().int(),
|
||||||
|
pctPositivacao: z.number(),
|
||||||
|
});
|
||||||
|
export type PositivacaoRep = z.infer<typeof PositivacaoRepSchema>;
|
||||||
|
|
||||||
|
export const RankingRepSchema = z.object({
|
||||||
|
codVendedor: z.number().int(),
|
||||||
|
nomeVendedor: z.string().nullable(),
|
||||||
|
pedidos: z.number().int(),
|
||||||
|
clientesAtendidos: z.number().int(),
|
||||||
|
faturamento: z.number(),
|
||||||
|
pctMeta: z.number(),
|
||||||
|
});
|
||||||
|
export type RankingRep = z.infer<typeof RankingRepSchema>;
|
||||||
|
|
||||||
|
// Clientes distintos positivados por dia do período (YYYY-MM-DD)
|
||||||
|
export const PositivacaoDiaSchema = z.object({
|
||||||
|
dia: z.string(),
|
||||||
|
clientes: z.number().int(),
|
||||||
|
});
|
||||||
|
export type PositivacaoDia = z.infer<typeof PositivacaoDiaSchema>;
|
||||||
|
|
||||||
|
export const ManagerDashboardSchema = z.object({
|
||||||
|
faturamentoMes: z.number(),
|
||||||
|
pedidosMes: z.number().int(),
|
||||||
|
ticketMedio: z.number(),
|
||||||
|
totalReps: z.number().int(),
|
||||||
|
promocoesAtivas: z.number().int(),
|
||||||
|
metaTotal: z.number(),
|
||||||
|
metasPorGrupo: z.array(MetaItemSchema).default([]),
|
||||||
|
rankingReps: z.array(RankingRepSchema),
|
||||||
|
positivacaoReps: z.array(PositivacaoRepSchema),
|
||||||
|
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
|
||||||
|
syncedAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;
|
||||||
|
|||||||
18
libs/shared/api-interface/src/lib/equipe.contract.ts
Normal file
18
libs/shared/api-interface/src/lib/equipe.contract.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const RepStatsSchema = z.object({
|
||||||
|
codVendedor: z.number().int(),
|
||||||
|
nomeVendedor: z.string().nullable(),
|
||||||
|
pedidosMes: z.number().int(),
|
||||||
|
faturamentoMes: z.number(),
|
||||||
|
ticketMedio: z.number(),
|
||||||
|
pctMeta: z.number(),
|
||||||
|
});
|
||||||
|
export type RepStats = z.infer<typeof RepStatsSchema>;
|
||||||
|
|
||||||
|
export const EquipeResponseSchema = z.object({
|
||||||
|
reps: z.array(RepStatsSchema),
|
||||||
|
totalReps: z.number().int(),
|
||||||
|
syncedAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export type EquipeResponse = z.infer<typeof EquipeResponseSchema>;
|
||||||
45
libs/shared/api-interface/src/lib/funil.contract.ts
Normal file
45
libs/shared/api-interface/src/lib/funil.contract.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ETAPAS_FUNIL = ['lead', 'proposta', 'negociacao', 'ganho', 'perdido'] as const;
|
||||||
|
export type EtapaFunil = (typeof ETAPAS_FUNIL)[number];
|
||||||
|
|
||||||
|
export const OportunidadeSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
idCliente: z.number().nullable(),
|
||||||
|
nomeProspect: z.string().nullable(),
|
||||||
|
empresaProspect: z.string().nullable(),
|
||||||
|
titulo: z.string(),
|
||||||
|
etapa: z.enum(ETAPAS_FUNIL),
|
||||||
|
valorEstimado: z.string().nullable(),
|
||||||
|
observacoes: z.string().nullable(),
|
||||||
|
idPedido: z.string().nullable(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
nomeCliente: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type Oportunidade = z.infer<typeof OportunidadeSchema>;
|
||||||
|
|
||||||
|
export const CreateOportunidadeSchema = z.object({
|
||||||
|
idCliente: z.number().int().positive().nullable().optional(),
|
||||||
|
nomeProspect: z.string().max(200).nullable().optional(),
|
||||||
|
empresaProspect: z.string().max(200).nullable().optional(),
|
||||||
|
titulo: z.string().min(1).max(200),
|
||||||
|
etapa: z.enum(ETAPAS_FUNIL).default('lead'),
|
||||||
|
valorEstimado: z.number().positive().nullable().optional(),
|
||||||
|
observacoes: z.string().max(2000).nullable().optional(),
|
||||||
|
});
|
||||||
|
export type CreateOportunidadeDto = z.infer<typeof CreateOportunidadeSchema>;
|
||||||
|
|
||||||
|
export const UpdateOportunidadeSchema = CreateOportunidadeSchema.partial().extend({
|
||||||
|
idPedido: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateOportunidadeDto = z.infer<typeof UpdateOportunidadeSchema>;
|
||||||
|
|
||||||
|
export const FunilResponseSchema = z.object({
|
||||||
|
lead: z.array(OportunidadeSchema),
|
||||||
|
proposta: z.array(OportunidadeSchema),
|
||||||
|
negociacao: z.array(OportunidadeSchema),
|
||||||
|
ganho: z.array(OportunidadeSchema),
|
||||||
|
perdido: z.array(OportunidadeSchema),
|
||||||
|
});
|
||||||
|
export type FunilResponse = z.infer<typeof FunilResponseSchema>;
|
||||||
@@ -11,6 +11,11 @@ export const SubscribePayloadSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
|
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
|
||||||
|
|
||||||
|
export const UnsubscribePayloadSchema = z.object({
|
||||||
|
endpoint: z.string().url(),
|
||||||
|
});
|
||||||
|
export type UnsubscribePayload = z.infer<typeof UnsubscribePayloadSchema>;
|
||||||
|
|
||||||
export const PendingCountResponseSchema = z.object({
|
export const PendingCountResponseSchema = z.object({
|
||||||
count: z.number().int().min(0),
|
count: z.number().int().min(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const SITUA_LABEL: Record<number, string> = {
|
|||||||
// ─── PedidoItem ───────────────────────────────────────────────────────────────
|
// ─── PedidoItem ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const PedidoItemSchema = z.object({
|
export const PedidoItemSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string(), // UUID para pedidos SAR; '{idPedido}-{ordem}' para ERP
|
||||||
idProduto: z.number().int(),
|
idProduto: z.number().int(),
|
||||||
codProduto: z.string().nullable(),
|
codProduto: z.string().nullable(),
|
||||||
descProduto: z.string().nullable(),
|
descProduto: z.string().nullable(),
|
||||||
@@ -87,6 +87,8 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
|
|||||||
acrescimo: z.string(),
|
acrescimo: z.string(),
|
||||||
comissao: z.string(),
|
comissao: z.string(),
|
||||||
pedFlex: z.string(),
|
pedFlex: z.string(),
|
||||||
|
formaPagamento: z.string().nullable().optional(),
|
||||||
|
endEntrega: z.string().nullable().optional(),
|
||||||
aprovadoPor: z.number().int().nullable(),
|
aprovadoPor: z.number().int().nullable(),
|
||||||
aprovadoEm: z.iso.datetime().nullable(),
|
aprovadoEm: z.iso.datetime().nullable(),
|
||||||
motivoRecusa: z.string().nullable(),
|
motivoRecusa: z.string().nullable(),
|
||||||
@@ -99,12 +101,16 @@ export type PedidoDetail = z.infer<typeof PedidoDetailSchema>;
|
|||||||
|
|
||||||
// ─── List query + response ────────────────────────────────────────────────────
|
// ─── List query + response ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Datas de filtro sempre YYYY-MM-DD — interpoladas em SQL no servidor, o formato
|
||||||
|
// fechado é parte da defesa contra injection, não só validação de UX.
|
||||||
|
export const DateOnlySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD');
|
||||||
|
|
||||||
export const PedidoListQuerySchema = z.object({
|
export const PedidoListQuerySchema = z.object({
|
||||||
idCliente: z.coerce.number().int().optional(),
|
idCliente: z.coerce.number().int().optional(),
|
||||||
situa: z.coerce.number().int().optional(),
|
situa: z.coerce.number().int().optional(),
|
||||||
numPedSar: z.string().optional(),
|
numPedSar: z.string().optional(),
|
||||||
from: z.string().optional(),
|
from: DateOnlySchema.optional(),
|
||||||
to: z.string().optional(),
|
to: DateOnlySchema.optional(),
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
});
|
});
|
||||||
@@ -137,6 +143,7 @@ export const CreatePedidoSchema = z.object({
|
|||||||
idPauta: z.number().int().optional(),
|
idPauta: z.number().int().optional(),
|
||||||
codFormapag: z.number().int().optional(),
|
codFormapag: z.number().int().optional(),
|
||||||
obs: z.string().optional(),
|
obs: z.string().optional(),
|
||||||
|
endEntrega: z.string().optional(),
|
||||||
idempotencyKey: z.string().optional(),
|
idempotencyKey: z.string().optional(),
|
||||||
itens: z.array(CreatePedidoItemSchema).min(1),
|
itens: z.array(CreatePedidoItemSchema).min(1),
|
||||||
});
|
});
|
||||||
@@ -152,3 +159,46 @@ export const RecusarPedidoSchema = z.object({
|
|||||||
motivo: z.string().min(1),
|
motivo: z.string().min(1),
|
||||||
});
|
});
|
||||||
export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>;
|
export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>;
|
||||||
|
|
||||||
|
// ─── ERP Consulta ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PedidoErpConsultaItemSchema = z.object({
|
||||||
|
numeroPedido: z.number().int(),
|
||||||
|
data: z.string(),
|
||||||
|
dtPrevent: z.string().nullable(),
|
||||||
|
dataEmissao: z.string().nullable(),
|
||||||
|
situa: z.number().int(),
|
||||||
|
statusDescr: z.string().nullable(),
|
||||||
|
idCliente: z.number().int(),
|
||||||
|
nomeCliente: z.string().nullable(),
|
||||||
|
razaoCliente: z.string().nullable(),
|
||||||
|
codVendedor: z.number().int(),
|
||||||
|
formaPagamento: z.string().nullable(),
|
||||||
|
total: z.string(),
|
||||||
|
obs: z.string().nullable(),
|
||||||
|
tipo: z.enum(['P', 'E']),
|
||||||
|
numPedSar: z.string().nullable(),
|
||||||
|
numeroEntrega: z.number().int().nullable(),
|
||||||
|
numeroNf: z.number().int().nullable(),
|
||||||
|
chaveAcessoNfe: z.string().nullable(),
|
||||||
|
sitPed: z.number().int().nullable(),
|
||||||
|
idPedido: z.number().int().nullable(),
|
||||||
|
});
|
||||||
|
export type PedidoErpConsultaItem = z.infer<typeof PedidoErpConsultaItemSchema>;
|
||||||
|
|
||||||
|
export const PedidoErpConsultaQuerySchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
from: DateOnlySchema.optional(),
|
||||||
|
to: DateOnlySchema.optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
});
|
||||||
|
export type PedidoErpConsultaQuery = z.infer<typeof PedidoErpConsultaQuerySchema>;
|
||||||
|
|
||||||
|
export const PedidoErpConsultaResponseSchema = z.object({
|
||||||
|
data: z.array(PedidoErpConsultaItemSchema),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
page: z.number().int().positive(),
|
||||||
|
limit: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
export type PedidoErpConsultaResponse = z.infer<typeof PedidoErpConsultaResponseSchema>;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe('PingResponseSchema', () => {
|
|||||||
status: 'ok',
|
status: 'ok',
|
||||||
service: 'sar-api',
|
service: 'sar-api',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
workspaceId: 'dev-workspace',
|
idEmpresa: 1,
|
||||||
requestId: '550e8400-e29b-41d4-a716-446655440000',
|
requestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
uptimeSeconds: 42,
|
uptimeSeconds: 42,
|
||||||
now: '2026-05-27T12:34:56.000Z',
|
now: '2026-05-27T12:34:56.000Z',
|
||||||
@@ -36,8 +36,8 @@ describe('PingResponseSchema', () => {
|
|||||||
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejeita workspaceId vazio', () => {
|
it('rejeita idEmpresa não inteiro', () => {
|
||||||
const bad = { ...validPayload, workspaceId: '' };
|
const bad = { ...validPayload, idEmpresa: 1.5 };
|
||||||
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
144
libs/shared/api-interface/src/lib/politicas.contract.ts
Normal file
144
libs/shared/api-interface/src/lib/politicas.contract.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const AlcadaDescontoItemSchema = z.object({
|
||||||
|
codVendedor: z.number().int(),
|
||||||
|
codGrupo: z.number().int(),
|
||||||
|
limitePerc: z.number(),
|
||||||
|
nomeVendedor: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type AlcadaDescontoItem = z.infer<typeof AlcadaDescontoItemSchema>;
|
||||||
|
|
||||||
|
export const AlcadaDescontosResponseSchema = z.object({
|
||||||
|
descontos: z.array(AlcadaDescontoItemSchema),
|
||||||
|
});
|
||||||
|
export type AlcadaDescontosResponse = z.infer<typeof AlcadaDescontosResponseSchema>;
|
||||||
|
|
||||||
|
export const UpsertDescontoBodySchema = z.object({
|
||||||
|
codVendedor: z.number().int().positive(),
|
||||||
|
codGrupo: z.number().int().default(0),
|
||||||
|
limitePerc: z.number().min(0).max(100),
|
||||||
|
});
|
||||||
|
export type UpsertDescontoBody = z.infer<typeof UpsertDescontoBodySchema>;
|
||||||
|
|
||||||
|
export const PromocaoSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
descricao: z.string(),
|
||||||
|
codProduto: z.string().nullable(),
|
||||||
|
grpProd: z.string().nullable(),
|
||||||
|
descPct: z.number(),
|
||||||
|
dataInicio: z.string(),
|
||||||
|
dataFim: z.string(),
|
||||||
|
ativa: z.boolean(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
});
|
||||||
|
export type Promocao = z.infer<typeof PromocaoSchema>;
|
||||||
|
|
||||||
|
export const PromocoesResponseSchema = z.object({
|
||||||
|
promocoes: z.array(PromocaoSchema),
|
||||||
|
});
|
||||||
|
export type PromocoesResponse = z.infer<typeof PromocoesResponseSchema>;
|
||||||
|
|
||||||
|
export const CreatePromocaoBodySchema = z.object({
|
||||||
|
descricao: z.string().min(1).max(255),
|
||||||
|
codProduto: z.string().optional(),
|
||||||
|
grpProd: z.string().optional(),
|
||||||
|
descPct: z.number().min(0).max(100),
|
||||||
|
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
});
|
||||||
|
export type CreatePromocaoBody = z.infer<typeof CreatePromocaoBodySchema>;
|
||||||
|
|
||||||
|
export const UpdatePromocaoBodySchema = CreatePromocaoBodySchema.partial().extend({
|
||||||
|
ativa: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
|
||||||
|
|
||||||
|
// ─── Regras de Desconto ───────────────────────────────────────────────────────
|
||||||
|
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
|
||||||
|
// produto e/ou representante, com vigência. Campos nulos = "todos".
|
||||||
|
|
||||||
|
export const RegraDescontoSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
descricao: z.string(),
|
||||||
|
codGrupo: z.number().int().nullable(),
|
||||||
|
grupo: z.string().nullable(),
|
||||||
|
codSubgrupo: z.number().int().nullable(),
|
||||||
|
subgrupo: z.string().nullable(),
|
||||||
|
codVendedor: z.number().int().nullable(),
|
||||||
|
nomeVendedor: z.string().nullable(),
|
||||||
|
descPct: z.number(),
|
||||||
|
dataInicio: z.string(),
|
||||||
|
dataFim: z.string(),
|
||||||
|
ativa: z.boolean(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
});
|
||||||
|
export type RegraDesconto = z.infer<typeof RegraDescontoSchema>;
|
||||||
|
|
||||||
|
export const RegrasDescontoResponseSchema = z.object({
|
||||||
|
regras: z.array(RegraDescontoSchema),
|
||||||
|
});
|
||||||
|
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
|
||||||
|
|
||||||
|
export const CreateRegraDescontoBodySchema = z.object({
|
||||||
|
descricao: z.string().min(1).max(200),
|
||||||
|
codGrupo: z.number().int().nullable().optional(),
|
||||||
|
codSubgrupo: z.number().int().nullable().optional(),
|
||||||
|
codVendedor: z.number().int().nullable().optional(),
|
||||||
|
descPct: z.number().min(0).max(100),
|
||||||
|
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
});
|
||||||
|
export type CreateRegraDescontoBody = z.infer<typeof CreateRegraDescontoBodySchema>;
|
||||||
|
|
||||||
|
export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.partial().extend({
|
||||||
|
ativa: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
|
||||||
|
|
||||||
|
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
|
||||||
|
export const PromocaoVigenteSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
descricao: z.string(),
|
||||||
|
codProduto: z.string().nullable(),
|
||||||
|
grpProd: z.string().nullable(),
|
||||||
|
descPct: z.number(),
|
||||||
|
dataFim: z.string(),
|
||||||
|
});
|
||||||
|
export type PromocaoVigente = z.infer<typeof PromocaoVigenteSchema>;
|
||||||
|
|
||||||
|
export const PromocoesVigentesResponseSchema = z.object({
|
||||||
|
promocoes: z.array(PromocaoVigenteSchema),
|
||||||
|
});
|
||||||
|
export type PromocoesVigentesResponse = z.infer<typeof PromocoesVigentesResponseSchema>;
|
||||||
|
|
||||||
|
// Regras vigentes para o representante logado — aplicadas na montagem do pedido
|
||||||
|
export const RegraVigenteSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
descricao: z.string(),
|
||||||
|
codGrupo: z.number().int().nullable(),
|
||||||
|
codSubgrupo: z.number().int().nullable(),
|
||||||
|
descPct: z.number(),
|
||||||
|
dataFim: z.string(),
|
||||||
|
});
|
||||||
|
export type RegraVigente = z.infer<typeof RegraVigenteSchema>;
|
||||||
|
|
||||||
|
export const RegrasVigentesResponseSchema = z.object({
|
||||||
|
regras: z.array(RegraVigenteSchema),
|
||||||
|
});
|
||||||
|
export type RegrasVigentesResponse = z.infer<typeof RegrasVigentesResponseSchema>;
|
||||||
|
|
||||||
|
// Grupos/subgrupos de produto (combos do formulário do gerente)
|
||||||
|
export const GrupoProdutoItemSchema = z.object({
|
||||||
|
codGrupo: z.number().int().nullable(),
|
||||||
|
grupo: z.string().nullable(),
|
||||||
|
codSubgrupo: z.number().int().nullable(),
|
||||||
|
subgrupo: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type GrupoProdutoItem = z.infer<typeof GrupoProdutoItemSchema>;
|
||||||
|
|
||||||
|
export const GruposProdutoResponseSchema = z.object({
|
||||||
|
grupos: z.array(GrupoProdutoItemSchema),
|
||||||
|
});
|
||||||
|
export type GruposProdutoResponse = z.infer<typeof GruposProdutoResponseSchema>;
|
||||||
@@ -20,6 +20,7 @@ export const ProdutoSummarySchema = z.object({
|
|||||||
ativo: z.number().int(),
|
ativo: z.number().int(),
|
||||||
qtdEstoque: z.string().nullable(),
|
qtdEstoque: z.string().nullable(),
|
||||||
listaParauta: z.number().int().nullable(),
|
listaParauta: z.number().int().nullable(),
|
||||||
|
loteMulVenda: z.number().int().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PautaSchema = z.object({
|
export const PautaSchema = z.object({
|
||||||
@@ -42,6 +43,14 @@ export type ProdutoSummary = z.infer<typeof ProdutoSummarySchema>;
|
|||||||
|
|
||||||
// ─── Produto Detail ───────────────────────────────────────────────────────────
|
// ─── Produto Detail ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PautaPrecoSchema = z.object({
|
||||||
|
idPauta: z.number().int(),
|
||||||
|
codigo: z.number().int(),
|
||||||
|
descricao: z.string(),
|
||||||
|
preco: z.string(),
|
||||||
|
});
|
||||||
|
export type PautaPreco = z.infer<typeof PautaPrecoSchema>;
|
||||||
|
|
||||||
export const ProdutoDetailSchema = ProdutoSummarySchema.extend({
|
export const ProdutoDetailSchema = ProdutoSummarySchema.extend({
|
||||||
referencia: z.string().nullable(),
|
referencia: z.string().nullable(),
|
||||||
descricaoDetalhada: z.string().nullable(),
|
descricaoDetalhada: z.string().nullable(),
|
||||||
@@ -50,9 +59,9 @@ export const ProdutoDetailSchema = ProdutoSummarySchema.extend({
|
|||||||
aliqIpi: z.string().nullable(),
|
aliqIpi: z.string().nullable(),
|
||||||
pesoLiquido: z.string().nullable(),
|
pesoLiquido: z.string().nullable(),
|
||||||
qtdVolume: z.string().nullable(),
|
qtdVolume: z.string().nullable(),
|
||||||
loteMulVenda: z.number().int().nullable(),
|
|
||||||
precoComIpi: z.string().nullable(),
|
precoComIpi: z.string().nullable(),
|
||||||
precoPromocional: z.string().nullable(),
|
precoPromocional: z.string().nullable(),
|
||||||
|
pautaPrecos: z.array(PautaPrecoSchema),
|
||||||
});
|
});
|
||||||
export type ProdutoDetail = z.infer<typeof ProdutoDetailSchema>;
|
export type ProdutoDetail = z.infer<typeof ProdutoDetailSchema>;
|
||||||
|
|
||||||
|
|||||||
73
libs/shared/api-interface/src/lib/report.contract.ts
Normal file
73
libs/shared/api-interface/src/lib/report.contract.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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(),
|
||||||
|
faturamentoTotal: z.string(),
|
||||||
|
participacaoPct: z.number(),
|
||||||
|
});
|
||||||
|
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(),
|
||||||
|
faturamentoRepTotal: z.string(),
|
||||||
|
});
|
||||||
|
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>;
|
||||||
127
libs/shared/api-interface/src/lib/sac.contract.ts
Normal file
127
libs/shared/api-interface/src/lib/sac.contract.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const TIPOS_CHAMADO = [
|
||||||
|
'avaria',
|
||||||
|
'quantidade_divergente',
|
||||||
|
'produto_errado',
|
||||||
|
'prazo_entrega',
|
||||||
|
'outro',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const STATUS_CHAMADO = [
|
||||||
|
'aberto',
|
||||||
|
'em_analise',
|
||||||
|
'aguardando_rep',
|
||||||
|
'resolvido',
|
||||||
|
'cancelado',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const RESOLUCAO_CHAMADO = [
|
||||||
|
'reposicao',
|
||||||
|
'desconto',
|
||||||
|
'cancelamento_item',
|
||||||
|
'sem_acao',
|
||||||
|
'outro',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const TIPO_CHAMADO_LABEL: Record<string, string> = {
|
||||||
|
avaria: 'Avaria / Dano',
|
||||||
|
quantidade_divergente: 'Quantidade Divergente',
|
||||||
|
produto_errado: 'Produto Errado',
|
||||||
|
prazo_entrega: 'Prazo de Entrega',
|
||||||
|
outro: 'Outro',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_CHAMADO_LABEL: Record<string, string> = {
|
||||||
|
aberto: 'Aberto',
|
||||||
|
em_analise: 'Em Análise',
|
||||||
|
aguardando_rep: 'Aguardando Rep.',
|
||||||
|
resolvido: 'Resolvido',
|
||||||
|
cancelado: 'Cancelado',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RESOLUCAO_CHAMADO_LABEL: Record<string, string> = {
|
||||||
|
reposicao: 'Reposição do Item',
|
||||||
|
desconto: 'Desconto / Abatimento',
|
||||||
|
cancelamento_item: 'Cancelamento do Item',
|
||||||
|
sem_acao: 'Sem Ação Necessária',
|
||||||
|
outro: 'Outro',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ItemAfetadoSchema = z.object({
|
||||||
|
codProduto: z.string(),
|
||||||
|
nomeProduto: z.string(),
|
||||||
|
qtd: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChamadoMensagemSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
idChamado: z.number(),
|
||||||
|
autor: z.enum(['rep', 'empresa']),
|
||||||
|
texto: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChamadoSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
idEmpresa: z.number(),
|
||||||
|
codVendedor: z.number(),
|
||||||
|
idPedido: z.string().nullable().optional(),
|
||||||
|
numPedSar: z.number().nullable().optional(),
|
||||||
|
numPedErp: z.number().nullable().optional(),
|
||||||
|
idCliente: z.number(),
|
||||||
|
nomeCliente: z.string().nullable().optional(),
|
||||||
|
tipo: z.string(),
|
||||||
|
assunto: z.string(),
|
||||||
|
descricao: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
resolucao: z.string().nullable().optional(),
|
||||||
|
notaResolucao: z.string().nullable().optional(),
|
||||||
|
itensAfetados: z.array(ItemAfetadoSchema).nullable().optional(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
mensagens: z.array(ChamadoMensagemSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateChamadoSchema = z.object({
|
||||||
|
idPedido: z.string().uuid().optional(),
|
||||||
|
numPedSar: z.number().int().positive().optional(),
|
||||||
|
numPedErp: z.number().int().positive().optional(),
|
||||||
|
nomeCliente: z.string().optional(),
|
||||||
|
idCliente: z.number().int().positive(),
|
||||||
|
tipo: z.enum(TIPOS_CHAMADO),
|
||||||
|
assunto: z.string().min(5).max(200),
|
||||||
|
descricao: z.string().min(10),
|
||||||
|
itensAfetados: z.array(ItemAfetadoSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AddMensagemSchema = z.object({
|
||||||
|
texto: z.string().min(1).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResolverChamadoSchema = z.object({
|
||||||
|
resolucao: z.enum(RESOLUCAO_CHAMADO),
|
||||||
|
notaResolucao: z.string().optional(),
|
||||||
|
mensagemFinal: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CancelChamadoSchema = z.object({
|
||||||
|
motivo: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChamadoListResponseSchema = z.object({
|
||||||
|
abertos: z.array(ChamadoSchema),
|
||||||
|
fechados: z.array(ChamadoSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TipoChamado = (typeof TIPOS_CHAMADO)[number];
|
||||||
|
export type StatusChamado = (typeof STATUS_CHAMADO)[number];
|
||||||
|
export type ResolucaoChamado = (typeof RESOLUCAO_CHAMADO)[number];
|
||||||
|
export type ItemAfetado = z.infer<typeof ItemAfetadoSchema>;
|
||||||
|
export type ChamadoMensagem = z.infer<typeof ChamadoMensagemSchema>;
|
||||||
|
export type Chamado = z.infer<typeof ChamadoSchema>;
|
||||||
|
export type CreateChamadoDto = z.infer<typeof CreateChamadoSchema>;
|
||||||
|
export type AddMensagemDto = z.infer<typeof AddMensagemSchema>;
|
||||||
|
export type ResolverChamadoDto = z.infer<typeof ResolverChamadoSchema>;
|
||||||
|
export type CancelChamadoDto = z.infer<typeof CancelChamadoSchema>;
|
||||||
|
export type ChamadoListResponse = z.infer<typeof ChamadoListResponseSchema>;
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"e2e": "nx run-many -t e2e",
|
"e2e": "nx run-many -t e2e",
|
||||||
"dev:api": "nx run api:serve",
|
"dev:api": "nx run api:serve",
|
||||||
"dev:web": "nx run web:serve",
|
"dev:web": "nx run web:serve",
|
||||||
"workspace:provision": "tsx scripts/provision-workspace.ts",
|
"client:provision": "tsx scripts/provision-client.ts",
|
||||||
"dev:up": "docker compose -f docker-compose.dev.yml up -d",
|
"dev:up": "docker compose -f docker-compose.dev.yml up -d",
|
||||||
"dev:down": "docker compose -f docker-compose.dev.yml down",
|
"dev:down": "docker compose -f docker-compose.dev.yml down",
|
||||||
"dev:logs": "docker compose -f docker-compose.dev.yml logs -f",
|
"dev:logs": "docker compose -f docker-compose.dev.yml logs -f",
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
"lru-cache": "^11.5.0",
|
"lru-cache": "^11.5.0",
|
||||||
"nestjs-cls": "^5.4.3",
|
"nestjs-cls": "^5.4.3",
|
||||||
"nestjs-pino": "^4.6.1",
|
"nestjs-pino": "^4.6.1",
|
||||||
"nestjs-zod": "^4.3.1",
|
"nestjs-zod": "^5.4.0",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"pino-http": "^10.5.0",
|
"pino-http": "^10.5.0",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user