feat(api,web): logo da empresa na impressao e cabecalho A4 compacto

- Tabela sar.config_empresa (logo em data URL por empresa matriz) com
  migration idempotente, espelho no schema SQL e GRANT no provision
- PUT /catalog/company/logo (gerente/admin) e logoBase64 no GET
  /catalog/company - vale para a impressao de todos os representantes
- Nova tela /ger/config "Configuracoes" com upload/preview/remocao do
  logo (PNG/JPG/WebP ate 500KB)
- Cabecalho da impressao redesenhado: logo a esquerda, dados da empresa
  condensados em 3 linhas, numero/status/data compactos a direita -
  ocupa ~metade da altura anterior no A4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 19:48:05 +00:00
parent b33293e79b
commit 8a5db57f35
11 changed files with 315 additions and 45 deletions

View File

@@ -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()
);

View File

@@ -195,6 +195,19 @@ model RegraDesconto {
@@map("regras_desconto") @@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 (Funil de Vendas) ──────────────────────────────────────────
// //
// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente) // Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente)

View File

@@ -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 { createZodDto } from 'nestjs-zod';
import { import {
ProdutoListQuerySchema, ProdutoListQuerySchema,
UpdateLogoBodySchema,
type EmpresaInfo, type EmpresaInfo,
type UpdateLogoBody,
type FormaPagamento, type FormaPagamento,
type Municipio, type Municipio,
type Pauta, type Pauta,
@@ -13,6 +25,7 @@ import {
import { CatalogService } from './catalog.service'; import { CatalogService } from './catalog.service';
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {} 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). // 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(); return this.catalog.company();
} }
@Put('company/logo')
@HttpCode(204)
updateLogo(@Body() body: UpdateLogoDto): Promise<void> {
return this.catalog.updateLogo(body as unknown as UpdateLogoBody);
}
@Get() @Get()
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> { list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery; const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { import type {
EmpresaInfo, EmpresaInfo,
UpdateLogoBody,
FormaPagamento, FormaPagamento,
Municipio, Municipio,
Pauta, Pauta,
@@ -101,6 +102,8 @@ export class CatalogService {
const r = rows[0]; const r = rows[0];
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`); 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 clean = (v: string | null) => {
const t = (v ?? '').trim(); const t = (v ?? '').trim();
return t === '' ? null : t; return t === '' ? null : t;
@@ -120,9 +123,28 @@ export class CatalogService {
cep: clean(r.cep), cep: clean(r.cep),
telefone: clean(r.telefone), telefone: clean(r.telefone),
email: clean(r.email), 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<void> {
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<Municipio[]> { async municipios(q?: string): Promise<Municipio[]> {
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');

View File

@@ -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<string> {
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 (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{msgCtx}
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Configurações
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Preferências da empresa aplicadas a todos os representantes
</Text>
</Flex>
<Card
title={
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faImage} style={{ color: 'var(--jcs-blue)' }} />
Logo da Empresa
</Flex>
}
style={{ maxWidth: 640 }}
>
{isLoading ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : (
<Flex vertical gap={16}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
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.
</Text>
<Flex align="center" gap={24} wrap="wrap">
<div
style={{
width: 220,
height: 90,
border: '1px dashed #CBD5E1',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#F8FAFC',
overflow: 'hidden',
}}
>
{empresa?.logoBase64 ? (
<img
src={empresa.logoBase64}
alt="Logo da empresa"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
) : (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Nenhum logo cadastrado
</Text>
)}
</div>
<Flex vertical gap={8}>
<Upload
accept="image/png,image/jpeg,image/webp"
showUploadList={false}
beforeUpload={beforeUpload}
>
<Button
type="primary"
icon={<FontAwesomeIcon icon={faUpload} />}
loading={uploading || updateLogo.isPending}
>
{empresa?.logoBase64 ? 'Trocar logo' : 'Enviar logo'}
</Button>
</Upload>
{empresa?.logoBase64 && (
<Popconfirm
title="Remover o logo?"
okText="Sim"
cancelText="Nao"
onConfirm={() => void removeLogo()}
>
<Button danger icon={<FontAwesomeIcon icon={faTrash} />}>
Remover
</Button>
</Popconfirm>
)}
</Flex>
</Flex>
</Flex>
)}
</Card>
</Flex>
);
}

View File

@@ -145,60 +145,71 @@ export function OrderPrintPage() {
color: INK, color: INK,
}} }}
> >
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */} {/* ── Cabeçalho compacto: logo + empresa matriz + nº do pedido ────── */}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'flex-start', alignItems: 'center',
padding: '22px 28px', gap: 16,
borderTop: `5px solid ${BLUE}`, padding: '12px 28px',
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)', borderTop: `4px solid ${BLUE}`,
borderBottom: `1px solid ${LINE}`, borderBottom: `2px solid ${BLUE}`,
}} }}
> >
<div style={{ maxWidth: 460 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}> {empresa?.logoBase64 && (
<img
src={empresa.logoBase64}
alt="Logo"
style={{ maxHeight: 52, maxWidth: 140, objectFit: 'contain', flexShrink: 0 }}
/>
)}
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 800, color: BLUE, lineHeight: 1.15 }}>
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'} {empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
</div> </div>
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div> <div style={{ fontSize: 8.5, color: MUTED, marginTop: 2, lineHeight: 1.45 }}>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}> {empresa?.razaoSocial && empresa.razaoSocial !== empresa.nomeFantasia && (
<>{empresa.razaoSocial} · </>
)}
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>} {empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>} {empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
{enderecoEmp && <div>{enderecoEmp}</div>} <br />
{cidadeEmp && <div>{cidadeEmp}</div>} {[enderecoEmp, cidadeEmp].filter(Boolean).join(' · ')}
{(empresa?.telefone || empresa?.email) && ( {(empresa?.telefone || empresa?.email) && (
<div> <>
<br />
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>} {empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
{empresa?.telefone && empresa?.email && <> · </>} {empresa?.telefone && empresa?.email && <> · </>}
{empresa?.email} {empresa?.email}
</div> </>
)} )}
</div> </div>
</div> </div>
<div style={{ textAlign: 'right' }}> </div>
<div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}> <div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 9, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
PEDIDO PEDIDO
</div> </div>
<div style={{ fontSize: 22, fontWeight: 800, color: INK, lineHeight: 1.1 }}> <div style={{ fontSize: 19, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
{order.numPedSar} {order.numPedSar}
</div> </div>
<div <div style={{ fontSize: 9.5, color: MUTED, marginTop: 3 }}>
<span
style={{ style={{
display: 'inline-block', display: 'inline-block',
marginTop: 6, padding: '1px 8px',
padding: '2px 10px',
borderRadius: 20, borderRadius: 20,
background: `${BLUE}12`, background: `${BLUE}12`,
color: BLUE, color: BLUE,
fontSize: 10.5,
fontWeight: 700, fontWeight: 700,
marginRight: 6,
}} }}
> >
{SITUA_LABEL[order.situa] ?? String(order.situa)} {SITUA_LABEL[order.situa] ?? String(order.situa)}
</div> </span>
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}> {dateBR(order.dtPedido)}
Emissão: {dateBR(order.dtPedido)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,7 @@ import {
faFileInvoiceDollar, faFileInvoiceDollar,
faTags, faTags,
faHeadset, faHeadset,
faGear,
} 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'; import { authStore } from '../../lib/auth-store';
@@ -102,6 +103,11 @@ const GERENTE_ITEMS: ItemType[] = [
label: 'Políticas Comerciais', label: 'Políticas Comerciais',
}, },
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' }, { key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
{
key: '/ger/config',
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
label: 'Configurações',
},
]; ];
function getItems(role: string): ItemType[] { function getItems(role: string): ItemType[] {

View File

@@ -27,6 +27,7 @@ import { EquipePage } from '../cockpits/ger/EquipePage';
import { PoliticasPage } from '../cockpits/ger/PoliticasPage'; import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
import { ChamadosPage } from '../cockpits/rep/ChamadosPage'; import { ChamadosPage } from '../cockpits/rep/ChamadosPage';
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage'; import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
import { GerConfigPage } from '../cockpits/ger/GerConfigPage';
import { authStore } from './auth-store'; import { authStore } from './auth-store';
function getRoleFromToken(): string { function getRoleFromToken(): string {
@@ -207,6 +208,12 @@ const gerChamadosRoute = createRoute({
component: GerChamadosPage, component: GerChamadosPage,
}); });
const gerConfigRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/config',
component: GerConfigPage,
});
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
rafaelRoute, rafaelRoute,
@@ -228,6 +235,7 @@ const routeTree = rootRoute.addChildren([
politicasRoute, politicasRoute,
chamadosRoute, chamadosRoute,
gerChamadosRoute, gerChamadosRoute,
gerConfigRoute,
]); ]);
export const router = createRouter({ export const router = createRouter({

View File

@@ -17,5 +17,18 @@ export const EmpresaInfoSchema = z.object({
cep: z.string().nullable(), cep: z.string().nullable(),
telefone: z.string().nullable(), telefone: z.string().nullable(),
email: 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<typeof EmpresaInfoSchema>; export type EmpresaInfo = z.infer<typeof EmpresaInfoSchema>;
// 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<typeof UpdateLogoBodySchema>;

View File

@@ -96,6 +96,7 @@ async function applyGrants(client: pg.Client, appRole: string): Promise<void> {
sar.push_subscription, sar.push_subscription,
sar.promocoes, sar.promocoes,
sar.regras_desconto, sar.regras_desconto,
sar.config_empresa,
sar.clientes_novos, sar.clientes_novos,
sar.contatos_novos sar.contatos_novos
TO ${appRole}; TO ${appRole};

View File

@@ -736,6 +736,16 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
created_by VARCHAR(100) NOT NULL 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) -- 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 -- GRANT INSERT, UPDATE, DELETE ON
-- sar.pedidos, sar.pedido_itens, sar.historico_pedido, -- sar.pedidos, sar.pedido_itens, sar.historico_pedido,
-- sar.alcada_desconto, sar.meta_representante, sar.push_subscription, -- 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 <role>; -- TO <role>;
-- ============================================================================= -- =============================================================================