Files
sar/apps/web/src/cockpits/ger/GerConfigPage.tsx
julian 8a5db57f35 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>
2026-07-22 19:48:05 +00:00

158 lines
5.4 KiB
TypeScript

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>
);
}