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")
|
@@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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
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,
|
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>
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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>;
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user