diff --git a/apps/api/prisma/migrations/20260722120000_add_config_empresa/migration.sql b/apps/api/prisma/migrations/20260722120000_add_config_empresa/migration.sql new file mode 100644 index 0000000..e18105e --- /dev/null +++ b/apps/api/prisma/migrations/20260722120000_add_config_empresa/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable: sar.config_empresa (configuracoes por empresa - logo da impressao) +-- logo_base64: data URL (data:image/png;base64,...) usado no cabecalho do pedido. +-- 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.config_empresa ( + id_empresa INTEGER PRIMARY KEY, + logo_base64 TEXT, + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 8c54bfd..4ced0d8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -195,6 +195,19 @@ model RegraDesconto { @@map("regras_desconto") } +// ─── ConfigEmpresa ─────────────────────────────────────────────────────────── +// +// Configurações por empresa. logo_base64: data URL exibido no cabeçalho da +// impressão do pedido de todos os representantes. + +model ConfigEmpresa { + idEmpresa Int @id @map("id_empresa") + logoBase64 String? @map("logo_base64") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("config_empresa") +} + // ─── Oportunidade (Funil de Vendas) ────────────────────────────────────────── // // Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente) diff --git a/apps/api/src/app/catalog/catalog.controller.ts b/apps/api/src/app/catalog/catalog.controller.ts index 2e0f8ae..c9b4b83 100644 --- a/apps/api/src/app/catalog/catalog.controller.ts +++ b/apps/api/src/app/catalog/catalog.controller.ts @@ -1,8 +1,20 @@ -import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + NotFoundException, + Param, + ParseIntPipe, + Put, + Query, +} from '@nestjs/common'; import { createZodDto } from 'nestjs-zod'; import { ProdutoListQuerySchema, + UpdateLogoBodySchema, type EmpresaInfo, + type UpdateLogoBody, type FormaPagamento, type Municipio, type Pauta, @@ -13,6 +25,7 @@ import { import { CatalogService } from './catalog.service'; class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {} +class UpdateLogoDto extends createZodDto(UpdateLogoBodySchema) {} // ADR 0006 revogado: UUID → Int para ID de produto. Sync removido (ERP direto via view). @@ -40,6 +53,12 @@ export class CatalogController { return this.catalog.company(); } + @Put('company/logo') + @HttpCode(204) + updateLogo(@Body() body: UpdateLogoDto): Promise { + return this.catalog.updateLogo(body as unknown as UpdateLogoBody); + } + @Get() list(@Query() query: ProdutoListQueryDto): Promise { const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery; diff --git a/apps/api/src/app/catalog/catalog.service.ts b/apps/api/src/app/catalog/catalog.service.ts index 408d9c3..c3e9628 100644 --- a/apps/api/src/app/catalog/catalog.service.ts +++ b/apps/api/src/app/catalog/catalog.service.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import type { EmpresaInfo, + UpdateLogoBody, FormaPagamento, Municipio, Pauta, @@ -101,6 +102,8 @@ export class CatalogService { const r = rows[0]; if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`); + const config = await prisma.configEmpresa.findUnique({ where: { idEmpresa } }); + const clean = (v: string | null) => { const t = (v ?? '').trim(); return t === '' ? null : t; @@ -120,9 +123,28 @@ export class CatalogService { cep: clean(r.cep), telefone: clean(r.telefone), email: clean(r.email), + logoBase64: config?.logoBase64 ?? null, }; } + // Logo do cabeçalho da impressão — só gerente/admin altera; vale para todos + // os representantes da empresa (gravado na matriz, mesma fonte do company()). + async updateLogo(body: UpdateLogoBody): Promise { + const role = this.cls.get('role') ?? 'rep'; + if (role !== 'manager' && role !== 'admin') { + throw new ForbiddenException('Apenas gerentes podem alterar o logo da empresa'); + } + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); + + await prisma.configEmpresa.upsert({ + where: { idEmpresa }, + create: { idEmpresa, logoBase64: body.logoBase64 }, + update: { logoBase64: body.logoBase64 }, + }); + } + async municipios(q?: string): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); diff --git a/apps/web/src/cockpits/ger/GerConfigPage.tsx b/apps/web/src/cockpits/ger/GerConfigPage.tsx new file mode 100644 index 0000000..cac4c11 --- /dev/null +++ b/apps/web/src/cockpits/ger/GerConfigPage.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import { Button, Card, Flex, Popconfirm, Skeleton, Typography, Upload, message } from 'antd'; +import type { UploadProps } from 'antd'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faImage, faTrash, faUpload } from '@fortawesome/free-solid-svg-icons'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCompany } from '../../lib/queries/company'; +import { apiFetch } from '../../lib/api-client'; + +const { Title, Text } = Typography; + +const MAX_LOGO_BYTES = 500 * 1024; // ~500KB — logo de cabeçalho não precisa mais + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(new Error('Falha ao ler o arquivo')); + reader.readAsDataURL(file); + }); +} + +export function GerConfigPage() { + const { data: empresa, isLoading } = useCompany(); + const qc = useQueryClient(); + const [msg, msgCtx] = message.useMessage(); + const [uploading, setUploading] = useState(false); + + const updateLogo = useMutation({ + mutationFn: (logoBase64: string | null) => + apiFetch('/catalog/company/logo', { method: 'PUT', body: { logoBase64 } }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['company'] }), + }); + + const beforeUpload: UploadProps['beforeUpload'] = async (file) => { + if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) { + void msg.error('Use uma imagem PNG, JPG ou WebP'); + return Upload.LIST_IGNORE; + } + if (file.size > MAX_LOGO_BYTES) { + void msg.error('Imagem muito grande — use um arquivo de até 500KB'); + return Upload.LIST_IGNORE; + } + setUploading(true); + try { + const dataUrl = await fileToDataUrl(file); + await updateLogo.mutateAsync(dataUrl); + void msg.success('Logo atualizado — já vale para as impressões de todos os representantes'); + } catch { + // erro já notificado pelo handler global do QueryClient + } finally { + setUploading(false); + } + return Upload.LIST_IGNORE; // upload manual — nada vai para action padrão + }; + + async function removeLogo() { + try { + await updateLogo.mutateAsync(null); + } catch { + return; + } + void msg.success('Logo removido'); + } + + return ( + + {msgCtx} + + + Configurações + + + Preferências da empresa aplicadas a todos os representantes + + + + + + Logo da Empresa + + } + style={{ maxWidth: 640 }} + > + {isLoading ? ( + + ) : ( + + + Aparece no cabeçalho da impressão do pedido (PDF) de todos os representantes da{' '} + {empresa?.nomeFantasia ?? empresa?.razaoSocial ?? 'empresa'}. PNG com fundo + transparente fica melhor. Máx. 500KB. + + + +
+ {empresa?.logoBase64 ? ( + Logo da empresa + ) : ( + + Nenhum logo cadastrado + + )} +
+ + + + + + {empresa?.logoBase64 && ( + void removeLogo()} + > + + + )} + +
+
+ )} + + + ); +} diff --git a/apps/web/src/cockpits/rep/OrderPrintPage.tsx b/apps/web/src/cockpits/rep/OrderPrintPage.tsx index 20a03d2..0f5de7c 100644 --- a/apps/web/src/cockpits/rep/OrderPrintPage.tsx +++ b/apps/web/src/cockpits/rep/OrderPrintPage.tsx @@ -145,60 +145,71 @@ export function OrderPrintPage() { color: INK, }} > - {/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */} + {/* ── Cabeçalho compacto: logo + empresa matriz + nº do pedido ────── */}
-
-
- {empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'} -
-
{empresa?.razaoSocial}
-
- {empresa?.cnpj && <>CNPJ {empresa.cnpj}} - {empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}} - {enderecoEmp &&
{enderecoEmp}
} - {cidadeEmp &&
{cidadeEmp}
} - {(empresa?.telefone || empresa?.email) && ( -
- {empresa?.telefone && <>Tel {phone(empresa.telefone)}} - {empresa?.telefone && empresa?.email && <> · } - {empresa?.email} -
- )} +
+ {empresa?.logoBase64 && ( + Logo + )} +
+
+ {empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'} +
+
+ {empresa?.razaoSocial && empresa.razaoSocial !== empresa.nomeFantasia && ( + <>{empresa.razaoSocial} · + )} + {empresa?.cnpj && <>CNPJ {empresa.cnpj}} + {empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}} +
+ {[enderecoEmp, cidadeEmp].filter(Boolean).join(' · ')} + {(empresa?.telefone || empresa?.email) && ( + <> +
+ {empresa?.telefone && <>Tel {phone(empresa.telefone)}} + {empresa?.telefone && empresa?.email && <> · } + {empresa?.email} + + )} +
-
-
+
+
PEDIDO
-
+
{order.numPedSar}
-
- {SITUA_LABEL[order.situa] ?? String(order.situa)} -
-
- Emissão: {dateBR(order.dtPedido)} +
+ + {SITUA_LABEL[order.situa] ?? String(order.situa)} + + {dateBR(order.dtPedido)}
diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 8e7646b..509db6b 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -12,6 +12,7 @@ import { faFileInvoiceDollar, faTags, faHeadset, + faGear, } from '@fortawesome/free-solid-svg-icons'; import type { ItemType } from 'antd/es/menu/interface'; import { authStore } from '../../lib/auth-store'; @@ -102,6 +103,11 @@ const GERENTE_ITEMS: ItemType[] = [ label: 'Políticas Comerciais', }, { key: '/ger/chamados', icon: , label: 'SAC' }, + { + key: '/ger/config', + icon: , + label: 'Configurações', + }, ]; function getItems(role: string): ItemType[] { diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 7889bc1..a9edb34 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -27,6 +27,7 @@ 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 { GerConfigPage } from '../cockpits/ger/GerConfigPage'; import { authStore } from './auth-store'; function getRoleFromToken(): string { @@ -207,6 +208,12 @@ const gerChamadosRoute = createRoute({ component: GerChamadosPage, }); +const gerConfigRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/ger/config', + component: GerConfigPage, +}); + const routeTree = rootRoute.addChildren([ indexRoute, rafaelRoute, @@ -228,6 +235,7 @@ const routeTree = rootRoute.addChildren([ politicasRoute, chamadosRoute, gerChamadosRoute, + gerConfigRoute, ]); export const router = createRouter({ diff --git a/libs/shared/api-interface/src/lib/company.contract.ts b/libs/shared/api-interface/src/lib/company.contract.ts index fc5e6fa..702d120 100644 --- a/libs/shared/api-interface/src/lib/company.contract.ts +++ b/libs/shared/api-interface/src/lib/company.contract.ts @@ -17,5 +17,18 @@ export const EmpresaInfoSchema = z.object({ cep: z.string().nullable(), telefone: z.string().nullable(), email: z.string().nullable(), + // Logo (data URL) exibido no cabeçalho da impressão — sar.config_empresa + logoBase64: z.string().nullable().optional(), }); export type EmpresaInfo = z.infer; + +// Upload do logo pelo gerente — data URL de imagem; null remove o logo. +// ~700KB de base64 = imagem de ~500KB, suficiente para logo de cabeçalho. +export const UpdateLogoBodySchema = z.object({ + logoBase64: z + .string() + .max(700_000) + .regex(/^data:image\/(png|jpeg|jpg|webp);base64,[A-Za-z0-9+/=]+$/) + .nullable(), +}); +export type UpdateLogoBody = z.infer; diff --git a/scripts/provision-client.ts b/scripts/provision-client.ts index 6a13186..a8db5fe 100644 --- a/scripts/provision-client.ts +++ b/scripts/provision-client.ts @@ -96,6 +96,7 @@ async function applyGrants(client: pg.Client, appRole: string): Promise { sar.push_subscription, sar.promocoes, sar.regras_desconto, + sar.config_empresa, sar.clientes_novos, sar.contatos_novos TO ${appRole}; diff --git a/scripts/sar-erp-schema.sql b/scripts/sar-erp-schema.sql index 1edb4f7..8c2854f 100644 --- a/scripts/sar-erp-schema.sql +++ b/scripts/sar-erp-schema.sql @@ -736,6 +736,16 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto ( created_by VARCHAR(100) NOT NULL ); +-- ----------------------------------------------------------------------------- +-- Configuracoes por empresa (logo da impressao do pedido, etc.) +-- logo_base64: data URL (data:image/png;base64,...) +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS sar.config_empresa ( + id_empresa INTEGER PRIMARY KEY, + logo_base64 TEXT, + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + -- ----------------------------------------------------------------------------- -- Oportunidades (Funil de Vendas) -- ----------------------------------------------------------------------------- @@ -820,6 +830,6 @@ CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagen -- GRANT INSERT, UPDATE, DELETE ON -- sar.pedidos, sar.pedido_itens, sar.historico_pedido, -- sar.alcada_desconto, sar.meta_representante, sar.push_subscription, --- sar.promocoes, sar.regras_desconto, sar.clientes_novos, sar.contatos_novos +-- sar.promocoes, sar.regras_desconto, sar.config_empresa, sar.clientes_novos, sar.contatos_novos -- TO ; -- =============================================================================