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:
@@ -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()
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
return this.catalog.updateLogo(body as unknown as UpdateLogoBody);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
|
||||
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
|
||||
|
||||
@@ -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<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[]> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
157
apps/web/src/cockpits/ger/GerConfigPage.tsx
Normal file
157
apps/web/src/cockpits/ger/GerConfigPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -145,60 +145,71 @@ export function OrderPrintPage() {
|
||||
color: INK,
|
||||
}}
|
||||
>
|
||||
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */}
|
||||
{/* ── Cabeçalho compacto: logo + empresa matriz + nº do pedido ────── */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: '22px 28px',
|
||||
borderTop: `5px solid ${BLUE}`,
|
||||
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)',
|
||||
borderBottom: `1px solid ${LINE}`,
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '12px 28px',
|
||||
borderTop: `4px solid ${BLUE}`,
|
||||
borderBottom: `2px solid ${BLUE}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 460 }}>
|
||||
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
|
||||
{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 ?? '...'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div>
|
||||
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}>
|
||||
<div style={{ fontSize: 8.5, color: MUTED, marginTop: 2, lineHeight: 1.45 }}>
|
||||
{empresa?.razaoSocial && empresa.razaoSocial !== empresa.nomeFantasia && (
|
||||
<>{empresa.razaoSocial} · </>
|
||||
)}
|
||||
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
|
||||
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
|
||||
{enderecoEmp && <div>{enderecoEmp}</div>}
|
||||
{cidadeEmp && <div>{cidadeEmp}</div>}
|
||||
<br />
|
||||
{[enderecoEmp, cidadeEmp].filter(Boolean).join(' · ')}
|
||||
{(empresa?.telefone || empresa?.email) && (
|
||||
<div>
|
||||
<>
|
||||
<br />
|
||||
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
|
||||
{empresa?.telefone && empresa?.email && <> · </>}
|
||||
{empresa?.email}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 9, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
|
||||
PEDIDO
|
||||
</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}
|
||||
</div>
|
||||
<div
|
||||
<div style={{ fontSize: 9.5, color: MUTED, marginTop: 3 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginTop: 6,
|
||||
padding: '2px 10px',
|
||||
padding: '1px 8px',
|
||||
borderRadius: 20,
|
||||
background: `${BLUE}12`,
|
||||
color: BLUE,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
marginRight: 6,
|
||||
}}
|
||||
>
|
||||
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}>
|
||||
Emissão: {dateBR(order.dtPedido)}
|
||||
</span>
|
||||
{dateBR(order.dtPedido)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
|
||||
{
|
||||
key: '/ger/config',
|
||||
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
|
||||
label: 'Configurações',
|
||||
},
|
||||
];
|
||||
|
||||
function getItems(role: string): ItemType[] {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<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>;
|
||||
|
||||
@@ -96,6 +96,7 @@ async function applyGrants(client: pg.Client, appRole: string): Promise<void> {
|
||||
sar.push_subscription,
|
||||
sar.promocoes,
|
||||
sar.regras_desconto,
|
||||
sar.config_empresa,
|
||||
sar.clientes_novos,
|
||||
sar.contatos_novos
|
||||
TO ${appRole};
|
||||
|
||||
@@ -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 <role>;
|
||||
-- =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user