🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { FileText, CalendarIcon } from 'lucide-react';
|
||||
import { useApontamentoDiarioChart } from '@/hooks/useApontamentoDiarioChart';
|
||||
import { useRelatorioDiario } from '@/hooks/useRelatorioDiario';
|
||||
import { RelatorioDiarioModal } from './RelatorioDiarioModal';
|
||||
import { formatBrazilianDateFromString } from '@/utils/dateTimeUtils';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const ApontamentoDiarioChart = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'diario' | 'ultimos7' | 'ultimos15' | 'ultimos30' | 'projecao7'>('ultimos30');
|
||||
const [selectedOF, setSelectedOF] = useState<string | null>(null);
|
||||
const [showRelatorio, setShowRelatorio] = useState(false);
|
||||
const [showRelatorioAnterior, setShowRelatorioAnterior] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date>();
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
|
||||
const { data: dadosProcessados, effectiveDate, loading, error } = useApontamentoDiarioChart(selectedPeriod);
|
||||
|
||||
// Usar a data efetiva do gráfico para o relatório do dia
|
||||
const { apontamentos, resumoProcessos, loading: loadingRelatorio } = useRelatorioDiario(effectiveDate);
|
||||
|
||||
// Usar a data selecionada para o relatório anterior - corrigir o formato
|
||||
const selectedDateString = selectedDate ? format(selectedDate, 'yyyy-MM-dd') : '';
|
||||
const { apontamentos: apontamentosAnteriores, resumoProcessos: resumoProcessosAnteriores, loading: loadingRelatorioAnterior } = useRelatorioDiario(selectedDateString);
|
||||
|
||||
console.log('ApontamentoDiarioChart - effectiveDate:', effectiveDate);
|
||||
console.log('ApontamentoDiarioChart - selectedDate objeto:', selectedDate);
|
||||
console.log('ApontamentoDiarioChart - selectedDateString formatado:', selectedDateString);
|
||||
console.log('ApontamentoDiarioChart - selectedPeriod:', selectedPeriod);
|
||||
|
||||
// Obter todas as OFs únicas para a legenda
|
||||
const ofsUnicas = useMemo(() => {
|
||||
const ofs = new Set<string>();
|
||||
dadosProcessados.forEach(item => {
|
||||
item.ofs.forEach(ofData => {
|
||||
ofs.add(ofData.ofNumber);
|
||||
});
|
||||
});
|
||||
return Array.from(ofs).sort();
|
||||
}, [dadosProcessados]);
|
||||
|
||||
// Calcular valor máximo e gerar escala Y otimizada
|
||||
const { valorMaximo, linhasGrid } = useMemo(() => {
|
||||
const maxTotal = Math.max(...dadosProcessados.map(item => item.total), 0);
|
||||
|
||||
if (maxTotal === 0) {
|
||||
return { valorMaximo: 0, linhasGrid: [] };
|
||||
}
|
||||
|
||||
// Encontrar um valor máximo "bonito" baseado no maior valor
|
||||
let escala = 1000; // Começar com 1000kg
|
||||
let valorMaximo = Math.ceil(maxTotal / escala) * escala;
|
||||
|
||||
// Se o valor for muito grande, usar escalas maiores
|
||||
if (maxTotal > 50000) {
|
||||
escala = 10000;
|
||||
valorMaximo = Math.ceil(maxTotal / escala) * escala;
|
||||
} else if (maxTotal > 10000) {
|
||||
escala = 5000;
|
||||
valorMaximo = Math.ceil(maxTotal / escala) * escala;
|
||||
}
|
||||
|
||||
// Garantir que temos pelo menos 5 divisões
|
||||
const numeroDivisoes = Math.max(5, Math.floor(valorMaximo / escala));
|
||||
const intervalo = valorMaximo / numeroDivisoes;
|
||||
|
||||
const linhas = Array.from({ length: numeroDivisoes + 1 }, (_, i) => ({
|
||||
valor: Math.round(i * intervalo),
|
||||
porcentagem: (i / numeroDivisoes) * 100
|
||||
}));
|
||||
|
||||
return { valorMaximo, linhasGrid: linhas };
|
||||
}, [dadosProcessados]);
|
||||
|
||||
// Cores para cada OF
|
||||
const coresOF = {
|
||||
'B114': 'bg-blue-500',
|
||||
'B117': 'bg-amber-500',
|
||||
'B118': 'bg-emerald-500',
|
||||
'B119': 'bg-purple-500',
|
||||
'B120': 'bg-pink-500',
|
||||
'B121': 'bg-indigo-500',
|
||||
'B122': 'bg-cyan-500',
|
||||
'B123': 'bg-teal-500',
|
||||
'B124': 'bg-lime-500',
|
||||
'B125': 'bg-red-500',
|
||||
};
|
||||
|
||||
const getCorOF = (ofNumber: string) => {
|
||||
return coresOF[ofNumber as keyof typeof coresOF] || 'bg-gray-500';
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
{ key: 'diario', label: 'Diário' },
|
||||
{ key: 'ultimos7', label: 'Últ. 7 dias' },
|
||||
{ key: 'ultimos15', label: 'Últ. 15 dias' },
|
||||
{ key: 'ultimos30', label: 'Últ. 30 dias' },
|
||||
{ key: 'projecao7', label: 'Média Próx. 7 dias' },
|
||||
];
|
||||
|
||||
// Filtrar dados por OF selecionada
|
||||
const dadosFiltrados = useMemo(() => {
|
||||
if (!selectedOF) return dadosProcessados;
|
||||
|
||||
return dadosProcessados.map(item => ({
|
||||
...item,
|
||||
ofs: item.ofs.filter(ofData => ofData.ofNumber === selectedOF),
|
||||
total: item.ofs.filter(ofData => ofData.ofNumber === selectedOF).reduce((sum, ofData) => sum + ofData.peso, 0)
|
||||
})).filter(item => item.total > 0);
|
||||
}, [dadosProcessados, selectedOF]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">Apontamento Diário da Produção</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">Carregando dados...</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">Apontamento Diário da Produção</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">Apontamento Diário da Produção</CardTitle>
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{buttons.map((button) => (
|
||||
<Button
|
||||
key={button.key}
|
||||
variant={selectedPeriod === button.key ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedPeriod(button.key as any)}
|
||||
className="text-xs md:text-sm"
|
||||
>
|
||||
{button.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRelatorio(true)}
|
||||
className="text-xs md:text-sm"
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Registros do Dia
|
||||
</Button>
|
||||
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs md:text-sm"
|
||||
>
|
||||
<CalendarIcon className="h-3 w-3 mr-1" />
|
||||
Registros Anteriores
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={(date) => {
|
||||
console.log('Data selecionada no calendário (objeto Date):', date);
|
||||
console.log('Data selecionada formatada:', date ? format(date, 'yyyy-MM-dd') : 'undefined');
|
||||
setSelectedDate(date);
|
||||
setCalendarOpen(false);
|
||||
if (date) {
|
||||
setShowRelatorioAnterior(true);
|
||||
}
|
||||
}}
|
||||
initialFocus
|
||||
className="p-3 pointer-events-auto"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{effectiveDate && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
Data de referência: {formatBrazilianDateFromString(effectiveDate)}
|
||||
</div>
|
||||
)}
|
||||
{ofsUnicas.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
OFs com dados no período: {ofsUnicas.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Legenda interativa */}
|
||||
<div className="flex justify-center flex-wrap gap-4 mb-8">
|
||||
{ofsUnicas.map((ofNumber) => (
|
||||
<button
|
||||
key={ofNumber}
|
||||
onClick={() => setSelectedOF(selectedOF === ofNumber ? null : ofNumber)}
|
||||
className={`flex items-center gap-2 px-3 py-1 rounded-md transition-all ${
|
||||
selectedOF === ofNumber
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded-sm ${getCorOF(ofNumber)}`} />
|
||||
<span className="text-sm font-medium">OF {ofNumber}</span>
|
||||
</button>
|
||||
))}
|
||||
{selectedOF && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedOF(null)}
|
||||
className="text-xs"
|
||||
>
|
||||
Todas OFs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gráfico de barras empilhadas com eixo Y otimizado */}
|
||||
<div className="relative overflow-x-auto pb-4">
|
||||
<div className="flex min-w-fit">
|
||||
{/* Eixo Y - apenas nas linhas horizontais */}
|
||||
<div className="relative w-16 h-80 mr-4">
|
||||
{/* Linhas horizontais com valores */}
|
||||
{linhasGrid.slice().reverse().map((linha) => (
|
||||
<div
|
||||
key={linha.valor}
|
||||
className="absolute right-0 flex items-center"
|
||||
style={{ bottom: `${linha.porcentagem}%`, transform: 'translateY(50%)' }}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground mr-2">
|
||||
{linha.valor >= 1000 ? `${(linha.valor/1000).toFixed(0)}t` : `${linha.valor}kg`}
|
||||
</span>
|
||||
<div className="w-2 h-px bg-border"></div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Label do eixo Y */}
|
||||
<div className="absolute -left-4 top-1/2 -rotate-90 text-xs text-muted-foreground whitespace-nowrap">
|
||||
Peso (Kg)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid lines horizontais */}
|
||||
<div className="absolute left-16 top-0 right-0 h-80 pointer-events-none">
|
||||
{linhasGrid.map((linha) => (
|
||||
<div
|
||||
key={linha.valor}
|
||||
className="absolute w-full border-t border-dashed border-muted-foreground/20"
|
||||
style={{ bottom: `${linha.porcentagem}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Barras */}
|
||||
<div className="flex justify-around items-end text-center flex-1 gap-4 relative z-10">
|
||||
{dadosFiltrados.map((item) => {
|
||||
const alturaTotal = valorMaximo > 0 ? (item.total / valorMaximo) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={item.processo} className="flex flex-col items-center px-2 min-w-16">
|
||||
{/* Valor total no topo */}
|
||||
<div className="text-sm font-bold text-foreground mb-1 h-6">
|
||||
{item.total > 0 ? `${item.total.toLocaleString('pt-BR')} Kg` : ''}
|
||||
</div>
|
||||
|
||||
{/* Barra empilhada */}
|
||||
<div
|
||||
className="relative flex flex-col-reverse w-12 bg-muted border border-border rounded-sm overflow-hidden"
|
||||
style={{ height: '288px' }} // 72 * 4 = altura fixa para o container
|
||||
>
|
||||
{item.ofs.map((ofData, index) => {
|
||||
const alturaSegmento = alturaTotal > 0 ? (ofData.percentage / 100) * alturaTotal : 0;
|
||||
const alturaPixels = (alturaSegmento / 100) * 288; // Converter para pixels
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${ofData.ofNumber}-${index}`}
|
||||
className={`w-full transition-all duration-300 cursor-pointer hover:opacity-85 flex items-center justify-center text-white text-xs font-semibold text-shadow ${getCorOF(ofData.ofNumber)}`}
|
||||
style={{ height: `${alturaPixels}px` }}
|
||||
title={`OF ${ofData.ofNumber}: ${ofData.peso.toLocaleString('pt-BR')} Kg`}
|
||||
>
|
||||
{alturaPixels > 20 && (
|
||||
<span className="transform rotate-90 whitespace-nowrap">
|
||||
{ofData.peso > 999 ? `${Math.round(ofData.peso/1000)}t` : `${ofData.peso}kg`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Nome do processo */}
|
||||
<div className="mt-2 text-sm font-semibold text-muted-foreground">
|
||||
{item.processo}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dadosFiltrados.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>Nenhum dado encontrado para o período selecionado</p>
|
||||
<p className="text-sm mt-2">
|
||||
Experimente selecionar um período mais amplo como "Últimos 30 dias"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RelatorioDiarioModal
|
||||
isOpen={showRelatorio}
|
||||
onClose={() => setShowRelatorio(false)}
|
||||
apontamentos={apontamentos}
|
||||
resumoProcessos={resumoProcessos}
|
||||
loading={loadingRelatorio}
|
||||
selectedDate={effectiveDate}
|
||||
/>
|
||||
|
||||
<RelatorioDiarioModal
|
||||
isOpen={showRelatorioAnterior}
|
||||
onClose={() => setShowRelatorioAnterior(false)}
|
||||
apontamentos={apontamentosAnteriores}
|
||||
resumoProcessos={resumoProcessosAnteriores}
|
||||
loading={loadingRelatorioAnterior}
|
||||
selectedDate={selectedDateString}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X, FileText, Download } from 'lucide-react';
|
||||
import { formatBrazilianDateFromString, formatBrazilianDateTime } from '@/utils/dateTimeUtils';
|
||||
import { RelatorioDiarioPDF } from './RelatorioDiarioPDF';
|
||||
|
||||
interface ApontamentoDiario {
|
||||
id: string;
|
||||
of_number: string;
|
||||
etapa_fase: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
processo: string;
|
||||
quantidade: number;
|
||||
peso_unitario: number;
|
||||
peso_total: number;
|
||||
data_apontamento: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ResumoProcesso {
|
||||
processo: string;
|
||||
quantidade: number;
|
||||
apontamentos: number;
|
||||
peso_total: number;
|
||||
}
|
||||
|
||||
interface RelatorioDiarioModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
apontamentos: ApontamentoDiario[];
|
||||
resumoProcessos: ResumoProcesso[];
|
||||
loading: boolean;
|
||||
selectedDate: string;
|
||||
}
|
||||
|
||||
export const RelatorioDiarioModal: React.FC<RelatorioDiarioModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
apontamentos,
|
||||
resumoProcessos,
|
||||
loading,
|
||||
selectedDate
|
||||
}) => {
|
||||
const estatisticas = useMemo(() => {
|
||||
const totalApontamentos = apontamentos.length;
|
||||
const totalQuantidade = apontamentos.reduce((sum, apt) => sum + apt.quantidade, 0);
|
||||
const totalPeso = apontamentos.reduce((sum, apt) => sum + apt.peso_total, 0);
|
||||
const processosUnicos = new Set(apontamentos.map(apt => apt.processo)).size;
|
||||
const ofsUnicas = new Set(apontamentos.map(apt => apt.of_number)).size;
|
||||
|
||||
return {
|
||||
totalApontamentos,
|
||||
totalQuantidade,
|
||||
totalPeso,
|
||||
processosUnicos,
|
||||
ofsUnicas
|
||||
};
|
||||
}, [apontamentos]);
|
||||
|
||||
const exportarCSV = () => {
|
||||
if (apontamentos.length === 0) return;
|
||||
|
||||
const headers = ['OF', 'Etapa/Fase', 'Marca', 'Descrição', 'Processo', 'Quantidade', 'Peso Unit.', 'Peso Total', 'Data', 'Horário'];
|
||||
|
||||
const csvData = apontamentos.map(apt => [
|
||||
apt.of_number,
|
||||
apt.etapa_fase,
|
||||
apt.marca,
|
||||
apt.descricao,
|
||||
apt.processo,
|
||||
apt.quantidade.toString(),
|
||||
apt.peso_unitario.toFixed(3),
|
||||
apt.peso_total.toFixed(3),
|
||||
formatBrazilianDateFromString(apt.data_apontamento),
|
||||
formatBrazilianDateTime(apt.created_at).split(' ')[1]
|
||||
]);
|
||||
|
||||
const csvContent = [headers, ...csvData]
|
||||
.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `relatorio-diario-${selectedDate}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Usar a função correta para formatação de strings de data
|
||||
const displayDate = selectedDate ? formatBrazilianDateFromString(selectedDate) : 'Não definido';
|
||||
|
||||
console.log('RelatorioDiarioModal - selectedDate:', selectedDate);
|
||||
console.log('RelatorioDiarioModal - displayDate formatado:', displayDate);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Relatório Diário de Produção
|
||||
</DialogTitle>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Cabeçalho com informações da data */}
|
||||
<div className="bg-primary/10 border border-primary/20 p-4 rounded-lg">
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Registros do dia {displayDate}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Relatório consolidado de apontamentos de produção
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">Carregando dados...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Estatísticas gerais */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg text-center">
|
||||
<h3 className="text-base font-semibold text-blue-800">Total de Peças</h3>
|
||||
<p className="text-3xl font-bold text-blue-900">{estatisticas.totalQuantidade}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 p-4 rounded-lg text-center">
|
||||
<h3 className="text-base font-semibold text-green-800">Nº de Apontamentos</h3>
|
||||
<p className="text-3xl font-bold text-green-900">{estatisticas.totalApontamentos}</p>
|
||||
</div>
|
||||
<div className="bg-orange-50 border border-orange-200 p-4 rounded-lg text-center">
|
||||
<h3 className="text-base font-semibold text-orange-800">Peso Total</h3>
|
||||
<p className="text-2xl font-bold text-orange-900">{estatisticas.totalPeso.toFixed(0)} Kg</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg text-center">
|
||||
<h3 className="text-base font-semibold text-gray-800">Nº de Processos</h3>
|
||||
<p className="text-3xl font-bold text-gray-900">{estatisticas.processosUnicos}</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 p-4 rounded-lg text-center">
|
||||
<h3 className="text-base font-semibold text-purple-800">Nº de OFs</h3>
|
||||
<p className="text-3xl font-bold text-purple-900">{estatisticas.ofsUnicas}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resumo por processo - 50% menor */}
|
||||
{resumoProcessos.length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<h3 className="text-lg font-semibold mb-3">Resumo por Processo</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{resumoProcessos.map((item) => (
|
||||
<div key={item.processo} className="bg-gray-50 p-2 rounded-lg">
|
||||
<h4 className="font-medium text-gray-800 text-sm">{item.processo}</h4>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
<p>{item.quantidade} peças ({item.apontamentos} apontamentos)</p>
|
||||
<p className="font-semibold text-gray-800">{item.peso_total.toFixed(2)} kg</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ações do relatório */}
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{apontamentos.length} registros encontrados
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<RelatorioDiarioPDF
|
||||
apontamentos={apontamentos}
|
||||
resumoProcessos={resumoProcessos}
|
||||
estatisticas={estatisticas}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
<Button
|
||||
onClick={exportarCSV}
|
||||
disabled={apontamentos.length === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabela de apontamentos */}
|
||||
{apontamentos.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border border-gray-200">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border border-gray-200 px-4 py-2 text-left font-semibold">OF</th>
|
||||
<th className="border border-gray-200 px-4 py-2 text-left font-semibold">Etapa/Fase</th>
|
||||
<th className="border border-gray-200 px-4 py-2 text-left font-semibold">Marca</th>
|
||||
<th className="border border-gray-200 px-4 py-2 text-left font-semibold">Descrição</th>
|
||||
<th className="border border-gray-200 px-4 py-2 text-left font-semibold">Processo</th>
|
||||
<th className="border border-gray-200 px-4 py-2 text-center font-semibold">Qtd</th>
|
||||
<th className="border border-gray-200 px-4 py-2 text-center font-semibold">Peso Unit.</th>
|
||||
<th className="border border-gray-200 px-4 py-2 text-center font-semibold">Peso Total</th>
|
||||
<th className="border border-gray-200 px-4 py-2 text-center font-semibold">Data</th>
|
||||
<th className="border border-gray-200 px-4 py-2 text-center font-semibold">Horário</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apontamentos.map((apt) => (
|
||||
<tr key={apt.id} className="hover:bg-gray-50">
|
||||
<td className="border border-gray-200 px-4 py-2 font-medium">{apt.of_number}</td>
|
||||
<td className="border border-gray-200 px-4 py-2">{apt.etapa_fase}</td>
|
||||
<td className="border border-gray-200 px-4 py-2">{apt.marca}</td>
|
||||
<td className="border border-gray-200 px-4 py-2">{apt.descricao}</td>
|
||||
<td className="border border-gray-200 px-4 py-2">{apt.processo}</td>
|
||||
<td className="border border-gray-200 px-4 py-2 text-center">{apt.quantidade}</td>
|
||||
<td className="border border-gray-200 px-4 py-2 text-center">{apt.peso_unitario.toFixed(3)} kg</td>
|
||||
<td className="border border-gray-200 px-4 py-2 text-center font-semibold">{apt.peso_total.toFixed(3)} kg</td>
|
||||
<td className="border border-gray-200 px-4 py-2 text-center">
|
||||
{formatBrazilianDateFromString(apt.data_apontamento)}
|
||||
</td>
|
||||
<td className="border border-gray-200 px-4 py-2 text-center">
|
||||
{formatBrazilianDateTime(apt.created_at).split(' ')[1]}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>Nenhum apontamento encontrado para esta data</p>
|
||||
<p className="text-sm mt-2">
|
||||
Verifique se existem apontamentos de produção registrados
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,385 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileText, Printer } from 'lucide-react';
|
||||
import { formatBrazilianDate } from '@/utils/dateTimeUtils';
|
||||
import { generateProfessionalPDF, printProfessionalPDF } from '@/utils/pdfGenerator';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ApontamentoDiario {
|
||||
id: string;
|
||||
of_number: string;
|
||||
etapa_fase: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
processo: string;
|
||||
quantidade: number;
|
||||
peso_unitario: number;
|
||||
peso_total: number;
|
||||
data_apontamento: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ResumoProcesso {
|
||||
processo: string;
|
||||
quantidade: number;
|
||||
apontamentos: number;
|
||||
peso_total: number;
|
||||
}
|
||||
|
||||
interface EstatisticasRelatorio {
|
||||
totalApontamentos: number;
|
||||
totalQuantidade: number;
|
||||
totalPeso: number;
|
||||
processosUnicos: number;
|
||||
ofsUnicas: number;
|
||||
}
|
||||
|
||||
interface RelatorioDiarioPDFProps {
|
||||
apontamentos: ApontamentoDiario[];
|
||||
resumoProcessos: ResumoProcesso[];
|
||||
estatisticas: EstatisticasRelatorio;
|
||||
selectedDate: string;
|
||||
}
|
||||
|
||||
export const RelatorioDiarioPDF: React.FC<RelatorioDiarioPDFProps> = ({
|
||||
apontamentos,
|
||||
resumoProcessos,
|
||||
estatisticas,
|
||||
selectedDate
|
||||
}) => {
|
||||
const getProcessColor = (processo: string) => {
|
||||
const processName = processo.toLowerCase();
|
||||
if (processName.includes('montagem')) return 'blue';
|
||||
if (processName.includes('expedição') || processName.includes('expedicao')) return 'green';
|
||||
if (processName.includes('corte')) return 'purple';
|
||||
if (processName.includes('pintura') || processName.includes('galv')) return 'yellow';
|
||||
if (processName.includes('solda')) return 'orange';
|
||||
return 'gray';
|
||||
};
|
||||
|
||||
const createPDFTemplate = () => {
|
||||
const template = document.createElement('div');
|
||||
template.id = 'relatorio-diario-pdf-template';
|
||||
template.style.fontFamily = 'Inter, sans-serif';
|
||||
template.style.backgroundColor = 'white';
|
||||
template.style.padding = '0';
|
||||
template.style.margin = '0';
|
||||
template.style.width = '170mm';
|
||||
template.style.minHeight = '100vh';
|
||||
template.style.boxSizing = 'border-box';
|
||||
|
||||
// Agrupar apontamentos por processo
|
||||
const apontamentosPorProcesso = apontamentos.reduce((acc, apontamento) => {
|
||||
if (!acc[apontamento.processo]) {
|
||||
acc[apontamento.processo] = [];
|
||||
}
|
||||
acc[apontamento.processo].push(apontamento);
|
||||
return acc;
|
||||
}, {} as Record<string, ApontamentoDiario[]>);
|
||||
|
||||
// Processos principais (coluna completa)
|
||||
const processosCompletos = Object.entries(apontamentosPorProcesso).filter(([processo, apontamentosDoProcesso]) => {
|
||||
return apontamentosDoProcesso.length > 5;
|
||||
});
|
||||
|
||||
// Processos menores (duas colunas)
|
||||
const processosMenores = Object.entries(apontamentosPorProcesso).filter(([processo, apontamentosDoProcesso]) => {
|
||||
return apontamentosDoProcesso.length <= 5;
|
||||
});
|
||||
|
||||
template.innerHTML = `
|
||||
<style>
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 15mm 20mm 15mm 20mm;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.page-break {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
.avoid-break {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.no-break {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
table {
|
||||
page-break-inside: avoid;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.process-section {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.process-header {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.statistics-grid {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Compactar para impressão */
|
||||
h1 { margin-bottom: 4px; }
|
||||
h2 { margin-bottom: 6px; margin-top: 8px; }
|
||||
h3 { margin-bottom: 4px; }
|
||||
section { margin-bottom: 8px; }
|
||||
header { margin-bottom: 8px; padding-bottom: 8px; }
|
||||
</style>
|
||||
|
||||
<!-- Container principal -->
|
||||
<div style="width: 100%; margin: 0 auto; padding: 0; box-sizing: border-box;">
|
||||
|
||||
<!-- Cabeçalho compacto -->
|
||||
<header style="display: flex; justify-content: space-between; align-items: flex-start; padding-bottom: 8px; border-bottom: 2px solid #e5e7eb; margin-bottom: 8px;" class="avoid-break">
|
||||
<div style="flex: 1;">
|
||||
<h1 style="font-size: 18px; font-weight: 700; color: #1f2937; margin: 0 0 4px 0;">Relatório Diário de Produção</h1>
|
||||
<p style="font-size: 11px; color: #6b7280; margin: 0;">Relatório consolidado de apontamentos de produção</p>
|
||||
</div>
|
||||
<div style="text-align: right; flex-shrink: 0;">
|
||||
<p style="font-size: 12px; font-weight: 600; color: #374151; margin: 0;">Data: ${formatBrazilianDate(selectedDate)}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Estatísticas Gerais compactas -->
|
||||
<section style="margin-bottom: 10px;" class="avoid-break">
|
||||
<h2 style="font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 6px;">Estatísticas Gerais</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; text-align: center;" class="statistics-grid">
|
||||
<div style="background-color: #f9fafb; padding: 6px; border-radius: 4px; border: 1px solid #e5e7eb;">
|
||||
<p style="font-size: 9px; font-weight: 500; color: #6b7280; margin: 0 0 2px 0;">Total de Peças</p>
|
||||
<p style="font-size: 14px; font-weight: 700; color: #1f2937; margin: 0;">${estatisticas.totalQuantidade}</p>
|
||||
</div>
|
||||
<div style="background-color: #f9fafb; padding: 6px; border-radius: 4px; border: 1px solid #e5e7eb;">
|
||||
<p style="font-size: 9px; font-weight: 500; color: #6b7280; margin: 0 0 2px 0;">Peso Total</p>
|
||||
<p style="font-size: 14px; font-weight: 700; color: #1f2937; margin: 0;">${estatisticas.totalPeso.toFixed(0)} kg</p>
|
||||
</div>
|
||||
<div style="background-color: #f9fafb; padding: 6px; border-radius: 4px; border: 1px solid #e5e7eb;">
|
||||
<p style="font-size: 9px; font-weight: 500; color: #6b7280; margin: 0 0 2px 0;">Nº de Apontamentos</p>
|
||||
<p style="font-size: 14px; font-weight: 700; color: #1f2937; margin: 0;">${estatisticas.totalApontamentos}</p>
|
||||
</div>
|
||||
<div style="background-color: #f9fafb; padding: 6px; border-radius: 4px; border: 1px solid #e5e7eb;">
|
||||
<p style="font-size: 9px; font-weight: 500; color: #6b7280; margin: 0 0 2px 0;">Nº de Processos</p>
|
||||
<p style="font-size: 14px; font-weight: 700; color: #1f2937; margin: 0;">${estatisticas.processosUnicos}</p>
|
||||
</div>
|
||||
<div style="background-color: #f9fafb; padding: 6px; border-radius: 4px; border: 1px solid #e5e7eb;">
|
||||
<p style="font-size: 9px; font-weight: 500; color: #6b7280; margin: 0 0 2px 0;">Nº de OFs</p>
|
||||
<p style="font-size: 14px; font-weight: 700; color: #1f2937; margin: 0;">${estatisticas.ofsUnicas}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Detalhamento por Processo compacto -->
|
||||
<section>
|
||||
<h2 style="font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 8px; border-top: 2px solid #e5e7eb; padding-top: 8px;" class="process-header">Detalhamento por Processo</h2>
|
||||
|
||||
<!-- Processos Completos -->
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; margin-bottom: 12px;">
|
||||
${processosCompletos.map(([processo, apontamentosDoProcesso]) => {
|
||||
const color = getProcessColor(processo);
|
||||
const resumoProcesso = resumoProcessos.find(r => r.processo === processo);
|
||||
const totalPecas = resumoProcesso?.quantidade || 0;
|
||||
const pesoTotal = resumoProcesso?.peso_total || 0;
|
||||
|
||||
const colorMap = {
|
||||
blue: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af', bgLight: '#eff6ff' },
|
||||
green: { bg: '#dcfce7', border: '#10b981', text: '#047857', bgLight: '#f0fdf4' },
|
||||
purple: { bg: '#e9d5ff', border: '#8b5cf6', text: '#6b21a8', bgLight: '#f3e8ff' },
|
||||
yellow: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e', bgLight: '#fffbeb' },
|
||||
orange: { bg: '#fed7aa', border: '#f97316', text: '#c2410c', bgLight: '#fff7ed' },
|
||||
gray: { bg: '#f3f4f6', border: '#6b7280', text: '#374151', bgLight: '#f9fafb' }
|
||||
};
|
||||
|
||||
const colors = colorMap[color as keyof typeof colorMap];
|
||||
|
||||
return `
|
||||
<div class="process-section avoid-break">
|
||||
<h3 style="font-size: 12px; font-weight: 700; color: ${colors.text}; background-color: ${colors.bg}; padding: 4px 8px; border-radius: 3px 3px 0 0; margin: 0;" class="process-header">Processo: ${processo}</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 4px; background-color: ${colors.bgLight}; padding: 4px; border-left: 2px solid ${colors.border}; border-right: 2px solid ${colors.border}; border-bottom: 2px solid ${colors.border}; border-radius: 0 0 3px 3px; margin-bottom: 4px;">
|
||||
<div style="text-align: center;">
|
||||
<p style="font-size: 9px; font-weight: 500; color: ${colors.text}; margin: 0 0 2px 0;">Total de Peças no Processo</p>
|
||||
<p style="font-size: 12px; font-weight: 700; color: ${colors.text}; margin: 0;">${totalPecas}</p>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<p style="font-size: 9px; font-weight: 500; color: ${colors.text}; margin: 0 0 2px 0;">Peso Total no Processo</p>
|
||||
<p style="font-size: 12px; font-weight: 700; color: ${colors.text}; margin: 0;">${pesoTotal.toFixed(0)} kg</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto; width: 100%;" class="no-break">
|
||||
<table style="width: 100%; background-color: white; border-collapse: collapse; border: 1px solid #e5e7eb;">
|
||||
<thead style="background-color: #f9fafb;">
|
||||
<tr>
|
||||
<th style="padding: 3px 4px; text-align: left; font-size: 8px; font-weight: 600; color: #6b7280; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb; width: 10%;">OF</th>
|
||||
<th style="padding: 3px 4px; text-align: left; font-size: 8px; font-weight: 600; color: #6b7280; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb; width: 10%;">Fase</th>
|
||||
<th style="padding: 3px 4px; text-align: left; font-size: 8px; font-weight: 600; color: #6b7280; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb; width: 15%;">Marca</th>
|
||||
<th style="padding: 3px 4px; text-align: left; font-size: 8px; font-weight: 600; color: #6b7280; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb; width: 45%;">Descrição</th>
|
||||
<th style="padding: 3px 4px; text-align: right; font-size: 8px; font-weight: 600; color: #6b7280; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb; width: 10%;">Qtd</th>
|
||||
<th style="padding: 3px 4px; text-align: right; font-size: 8px; font-weight: 600; color: #6b7280; border-bottom: 1px solid #e5e7eb; width: 10%;">Peso</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${apontamentosDoProcesso.map(apontamento => `
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 2px 4px; font-size: 8px; border-right: 1px solid #e5e7eb;">${apontamento.of_number}</td>
|
||||
<td style="padding: 2px 4px; font-size: 8px; border-right: 1px solid #e5e7eb;">${apontamento.etapa_fase}</td>
|
||||
<td style="padding: 2px 4px; font-size: 8px; border-right: 1px solid #e5e7eb;">${apontamento.marca}</td>
|
||||
<td style="padding: 2px 4px; font-size: 8px; border-right: 1px solid #e5e7eb;">${apontamento.descricao}</td>
|
||||
<td style="padding: 2px 4px; text-align: right; font-size: 8px; border-right: 1px solid #e5e7eb;">${apontamento.quantidade}</td>
|
||||
<td style="padding: 2px 4px; text-align: right; font-size: 8px; font-weight: 600;">${apontamento.peso_total.toFixed(0)} kg</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Processos Menores (duas colunas) compactos -->
|
||||
${processosMenores.length > 0 ? `
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
${processosMenores.map(([processo, apontamentosDoProcesso]) => {
|
||||
const color = getProcessColor(processo);
|
||||
const resumoProcesso = resumoProcessos.find(r => r.processo === processo);
|
||||
const totalPecas = resumoProcesso?.quantidade || 0;
|
||||
const pesoTotal = resumoProcesso?.peso_total || 0;
|
||||
|
||||
const colorMap = {
|
||||
blue: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af', bgLight: '#eff6ff' },
|
||||
green: { bg: '#dcfce7', border: '#10b981', text: '#047857', bgLight: '#f0fdf4' },
|
||||
purple: { bg: '#e9d5ff', border: '#8b5cf6', text: '#6b21a8', bgLight: '#f3e8ff' },
|
||||
yellow: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e', bgLight: '#fffbeb' },
|
||||
orange: { bg: '#fed7aa', border: '#f97316', text: '#c2410c', bgLight: '#fff7ed' },
|
||||
gray: { bg: '#f3f4f6', border: '#6b7280', text: '#374151', bgLight: '#f9fafb' }
|
||||
};
|
||||
|
||||
const colors = colorMap[color as keyof typeof colorMap];
|
||||
|
||||
return `
|
||||
<div class="process-section avoid-break">
|
||||
<h3 style="font-size: 10px; font-weight: 700; color: ${colors.text}; background-color: ${colors.bg}; padding: 3px 6px; border-radius: 2px 2px 0 0; margin: 0;" class="process-header">Processo: ${processo}</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 3px; background-color: ${colors.bgLight}; padding: 3px; border-left: 2px solid ${colors.border}; border-right: 2px solid ${colors.border}; border-bottom: 2px solid ${colors.border}; border-radius: 0 0 2px 2px; margin-bottom: 3px;">
|
||||
<div style="text-align: center;">
|
||||
<p style="font-size: 7px; font-weight: 500; color: ${colors.text}; margin: 0 0 1px 0;">Total Peças</p>
|
||||
<p style="font-size: 10px; font-weight: 700; color: ${colors.text}; margin: 0;">${totalPecas}</p>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<p style="font-size: 7px; font-weight: 500; color: ${colors.text}; margin: 0 0 1px 0;">Peso Total</p>
|
||||
<p style="font-size: 10px; font-weight: 700; color: ${colors.text}; margin: 0;">${pesoTotal.toFixed(0)} kg</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto; width: 100%;" class="no-break">
|
||||
<table style="width: 100%; background-color: white; border-collapse: collapse; border: 1px solid #e5e7eb;">
|
||||
<thead style="background-color: #f9fafb;">
|
||||
<tr>
|
||||
<th style="padding: 2px 3px; text-align: left; font-size: 7px; font-weight: 600; color: #6b7280; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">Marca</th>
|
||||
<th style="padding: 2px 3px; text-align: right; font-size: 7px; font-weight: 600; color: #6b7280; border-bottom: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">Qtd</th>
|
||||
<th style="padding: 2px 3px; text-align: right; font-size: 7px; font-weight: 600; color: #6b7280; border-bottom: 1px solid #e5e7eb;">Peso</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${apontamentosDoProcesso.map(apontamento => `
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 2px 3px; font-size: 7px; border-right: 1px solid #e5e7eb;">${apontamento.marca}</td>
|
||||
<td style="padding: 2px 3px; text-align: right; font-size: 7px; border-right: 1px solid #e5e7eb;">${apontamento.quantidade}</td>
|
||||
<td style="padding: 2px 3px; text-align: right; font-size: 7px; font-weight: 600;">${apontamento.peso_total.toFixed(0)} kg</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return template;
|
||||
};
|
||||
|
||||
const gerarPDF = async () => {
|
||||
try {
|
||||
const template = createPDFTemplate();
|
||||
|
||||
// Adicionar template ao DOM temporariamente
|
||||
document.body.appendChild(template);
|
||||
|
||||
// Gerar PDF
|
||||
const filename = `relatorio-diario-producao-${selectedDate}.pdf`;
|
||||
await generateProfessionalPDF('relatorio-diario-pdf-template', filename);
|
||||
|
||||
// Remover template do DOM
|
||||
document.body.removeChild(template);
|
||||
|
||||
toast.success('PDF gerado com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PDF:', error);
|
||||
toast.error('Erro ao gerar PDF');
|
||||
}
|
||||
};
|
||||
|
||||
const imprimirRelatorio = async () => {
|
||||
try {
|
||||
const template = createPDFTemplate();
|
||||
|
||||
// Adicionar template ao DOM temporariamente
|
||||
document.body.appendChild(template);
|
||||
|
||||
// Imprimir
|
||||
await printProfessionalPDF('relatorio-diario-pdf-template');
|
||||
|
||||
// Remover template do DOM
|
||||
document.body.removeChild(template);
|
||||
|
||||
toast.success('Impressão iniciada com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao imprimir:', error);
|
||||
toast.error('Erro ao imprimir relatório');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={gerarPDF}
|
||||
disabled={apontamentos.length === 0}
|
||||
className="flex items-center gap-2 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
GERAR PDF
|
||||
</Button>
|
||||
<Button
|
||||
onClick={imprimirRelatorio}
|
||||
disabled={apontamentos.length === 0}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
IMPRIMIR
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { X, FileText, Printer } from 'lucide-react';
|
||||
import { useOFs } from '@/hooks/useOFs';
|
||||
import { useRelatorioPecasProcesso, PecaComStatus } from '@/hooks/useRelatorioPecasProcesso';
|
||||
import { RelatorioPecasProcessoPDF } from './RelatorioPecasProcessoPDF';
|
||||
import { RelatorioPecasProcessoPrint } from './RelatorioPecasProcessoPrint';
|
||||
|
||||
interface RelatorioPecasProcessoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const RelatorioPecasProcessoModal: React.FC<RelatorioPecasProcessoModalProps> = ({
|
||||
isOpen,
|
||||
onClose
|
||||
}) => {
|
||||
const [selectedOF, setSelectedOF] = useState('');
|
||||
const [selectedFase, setSelectedFase] = useState('todas');
|
||||
const { ofs, loading: ofsLoading } = useOFs();
|
||||
const { pecasComStatus, resumoProcessos, loading } = useRelatorioPecasProcesso(selectedOF);
|
||||
|
||||
// Filtrar peças por fase selecionada
|
||||
const pecasFiltradas = selectedFase === 'todas'
|
||||
? pecasComStatus
|
||||
: pecasComStatus.filter(peca => peca.etapa_fase === selectedFase);
|
||||
|
||||
// Recalcular estatísticas para as peças filtradas
|
||||
const estatisticasFiltradas = {
|
||||
totalPecas: pecasFiltradas.length,
|
||||
pesoTotalCorte: pecasFiltradas.filter(p => p.processos.corte).reduce((sum, p) => sum + p.peso_total, 0),
|
||||
pesoTotalSolda: pecasFiltradas.filter(p => p.processos.solda).reduce((sum, p) => sum + p.peso_total, 0),
|
||||
pesoTotalPintura: pecasFiltradas.filter(p => p.processos.pintura).reduce((sum, p) => sum + p.peso_total, 0),
|
||||
pesoTotalExpedicao: pecasFiltradas.filter(p => p.processos.expedicao).reduce((sum, p) => sum + p.peso_total, 0)
|
||||
};
|
||||
|
||||
// Obter fases únicas das peças
|
||||
const fasesUnicas = [...new Set(pecasComStatus.map(peca => peca.etapa_fase))].sort((a, b) => {
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
return collator.compare(a, b);
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between text-card-foreground">
|
||||
<span>Relatório de Peças por Processo</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Filtros */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 border rounded-lg bg-muted/50">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-card-foreground">Selecione a OF:</label>
|
||||
<Select value={selectedOF} onValueChange={setSelectedOF}>
|
||||
<SelectTrigger className="bg-background border-border text-foreground">
|
||||
<SelectValue placeholder={ofsLoading ? "Carregando..." : "Selecione uma OF"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ofs.map((of) => (
|
||||
<SelectItem key={of.id} value={of.num_of}>
|
||||
OF {of.num_of} {of.descritivo ? `- ${of.descritivo}` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-card-foreground">Selecione a Fase:</label>
|
||||
<Select value={selectedFase} onValueChange={setSelectedFase} disabled={!selectedOF}>
|
||||
<SelectTrigger className="bg-background border-border text-foreground">
|
||||
<SelectValue placeholder="Selecione uma fase" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todas as Fases</SelectItem>
|
||||
{fasesUnicas.map((fase) => (
|
||||
<SelectItem key={fase} value={fase}>
|
||||
Fase {fase}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões de Ação */}
|
||||
{selectedOF && (
|
||||
<div className="flex gap-2 justify-center">
|
||||
<RelatorioPecasProcessoPDF
|
||||
pecasComStatus={pecasFiltradas}
|
||||
estatisticas={estatisticasFiltradas}
|
||||
selectedOF={selectedOF}
|
||||
selectedFase={selectedFase}
|
||||
/>
|
||||
<RelatorioPecasProcessoPrint
|
||||
pecasComStatus={pecasFiltradas}
|
||||
estatisticas={estatisticasFiltradas}
|
||||
selectedOF={selectedOF}
|
||||
selectedFase={selectedFase}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview da Tabela */}
|
||||
{selectedOF && pecasFiltradas.length > 0 && (
|
||||
<div className="border rounded-lg bg-background">
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-4">
|
||||
Preview - OF {selectedOF} {selectedFase !== 'todas' ? `- Fase ${selectedFase}` : ''}
|
||||
</h3>
|
||||
|
||||
{/* Estatísticas */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-muted/50 p-3 rounded text-center">
|
||||
<p className="text-sm text-muted-foreground">Total de Peças</p>
|
||||
<p className="text-xl font-bold text-card-foreground">{estatisticasFiltradas.totalPecas}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 p-3 rounded text-center">
|
||||
<p className="text-sm text-muted-foreground">Peso Corte</p>
|
||||
<p className="text-xl font-bold text-card-foreground">{estatisticasFiltradas.pesoTotalCorte.toFixed(0)} kg</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 p-3 rounded text-center">
|
||||
<p className="text-sm text-muted-foreground">Peso Solda</p>
|
||||
<p className="text-xl font-bold text-card-foreground">{estatisticasFiltradas.pesoTotalSolda.toFixed(0)} kg</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 p-3 rounded text-center">
|
||||
<p className="text-sm text-muted-foreground">Peso Pintura</p>
|
||||
<p className="text-xl font-bold text-card-foreground">{estatisticasFiltradas.pesoTotalPintura.toFixed(0)} kg</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 p-3 rounded text-center">
|
||||
<p className="text-sm text-muted-foreground">Peso Expedição</p>
|
||||
<p className="text-xl font-bold text-card-foreground">{estatisticasFiltradas.pesoTotalExpedicao.toFixed(0)} kg</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabela Preview */}
|
||||
<div className="overflow-x-auto max-h-96">
|
||||
<table className="w-full border-collapse border border-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="border border-border p-2 text-left text-xs font-medium text-muted-foreground">OF</th>
|
||||
<th className="border border-border p-2 text-left text-xs font-medium text-muted-foreground">Fase</th>
|
||||
<th className="border border-border p-2 text-left text-xs font-medium text-muted-foreground">Marca</th>
|
||||
<th className="border border-border p-2 text-center text-xs font-medium text-muted-foreground">Qtd</th>
|
||||
<th className="border border-border p-2 text-right text-xs font-medium text-muted-foreground">Peso Unit.</th>
|
||||
<th className="border border-border p-2 text-right text-xs font-medium text-muted-foreground">Peso Total</th>
|
||||
<th className="border border-border p-2 text-center text-xs font-medium text-muted-foreground">Corte</th>
|
||||
<th className="border border-border p-2 text-center text-xs font-medium text-muted-foreground">Solda</th>
|
||||
<th className="border border-border p-2 text-center text-xs font-medium text-muted-foreground">Pint/Galv</th>
|
||||
<th className="border border-border p-2 text-center text-xs font-medium text-muted-foreground">Expedição</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pecasFiltradas.slice(0, 20).map((peca, index) => (
|
||||
<tr key={`${peca.id}-${index}`} className="hover:bg-muted/20">
|
||||
<td className="border border-border p-2 text-xs text-card-foreground">{peca.of_number}</td>
|
||||
<td className="border border-border p-2 text-xs text-card-foreground">{peca.etapa_fase}</td>
|
||||
<td className="border border-border p-2 text-xs text-card-foreground">{peca.marca}</td>
|
||||
<td className="border border-border p-2 text-xs text-center text-card-foreground">{peca.quantidade}</td>
|
||||
<td className="border border-border p-2 text-xs text-right text-card-foreground">{peca.peso_unitario.toFixed(2)} kg</td>
|
||||
<td className="border border-border p-2 text-xs text-right text-card-foreground">{peca.peso_total.toFixed(2)} kg</td>
|
||||
<td className="border border-border p-2 text-xs text-center text-card-foreground">
|
||||
{peca.processos.corte ? '✓' : ''}
|
||||
</td>
|
||||
<td className="border border-border p-2 text-xs text-center text-card-foreground">
|
||||
{!peca.tem_componentes ? 'S/M' : (peca.processos.solda ? '✓' : '')}
|
||||
</td>
|
||||
<td className="border border-border p-2 text-xs text-center text-card-foreground">
|
||||
{peca.processos.pintura ? '✓' : ''}
|
||||
</td>
|
||||
<td className="border border-border p-2 text-xs text-center text-card-foreground">
|
||||
{peca.processos.expedicao ? '✓' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{pecasFiltradas.length > 20 && (
|
||||
<tr>
|
||||
<td colSpan={10} className="border border-border p-2 text-center text-sm text-muted-foreground">
|
||||
... e mais {pecasFiltradas.length - 20} peças
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">Carregando dados...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOF && !loading && pecasFiltradas.length === 0 && (
|
||||
<div className="text-center p-8">
|
||||
<p className="text-muted-foreground">Nenhuma peça encontrada para os filtros selecionados.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { PecaComStatus } from '@/hooks/useRelatorioPecasProcesso';
|
||||
import { toast } from 'sonner';
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
interface RelatorioPecasProcessoPDFProps {
|
||||
pecasComStatus: PecaComStatus[];
|
||||
estatisticas: {
|
||||
totalPecas: number;
|
||||
pesoTotalCorte: number;
|
||||
pesoTotalSolda: number;
|
||||
pesoTotalPintura: number;
|
||||
pesoTotalExpedicao: number;
|
||||
};
|
||||
selectedOF: string;
|
||||
selectedFase: string;
|
||||
}
|
||||
|
||||
export const RelatorioPecasProcessoPDF: React.FC<RelatorioPecasProcessoPDFProps> = ({
|
||||
pecasComStatus,
|
||||
estatisticas,
|
||||
selectedOF,
|
||||
selectedFase
|
||||
}) => {
|
||||
const generatePDF = () => {
|
||||
if (pecasComStatus.length === 0) {
|
||||
toast.error('Nenhuma peça disponível para gerar PDF.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = new jsPDF('l', 'mm', 'a4'); // Landscape orientation
|
||||
|
||||
const faseText = selectedFase === 'todas' ? '' : ` - Fase ${selectedFase}`;
|
||||
|
||||
// Título
|
||||
doc.setFontSize(20);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Relatório de Status', 20, 20);
|
||||
|
||||
// Subtítulo
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Ordem de Fabricação: ${selectedOF}${faseText}`, 20, 30);
|
||||
|
||||
// Data/hora
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 20, 40);
|
||||
|
||||
// Resumo por Processo
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Resumo por Processo', 20, 55);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
let yPos = 65;
|
||||
doc.text(`Total de Peças: ${estatisticas.totalPecas}`, 20, yPos);
|
||||
doc.text(`Peso Corte: ${estatisticas.pesoTotalCorte.toFixed(2)} kg`, 80, yPos);
|
||||
doc.text(`Peso Solda: ${estatisticas.pesoTotalSolda.toFixed(2)} kg`, 140, yPos);
|
||||
doc.text(`Peso Pintura: ${estatisticas.pesoTotalPintura.toFixed(2)} kg`, 200, yPos);
|
||||
doc.text(`Peso Expedição: ${estatisticas.pesoTotalExpedicao.toFixed(2)} kg`, 260, yPos);
|
||||
|
||||
// Preparar dados para a tabela
|
||||
const tableData = pecasComStatus.map(peca => [
|
||||
peca.of_number,
|
||||
peca.etapa_fase,
|
||||
peca.marca,
|
||||
peca.quantidade.toString(),
|
||||
`${peca.peso_unitario.toFixed(2)} kg`,
|
||||
`${peca.peso_total.toFixed(2)} kg`,
|
||||
peca.processos.corte ? 'OK' : '',
|
||||
!peca.tem_componentes ? 'S/M' : (peca.processos.solda ? 'OK' : ''),
|
||||
peca.processos.pintura ? 'OK' : '',
|
||||
peca.processos.expedicao ? 'OK' : ''
|
||||
]);
|
||||
|
||||
// Cabeçalhos da tabela
|
||||
const headers = [
|
||||
['OF', 'Fase', 'Marca', 'Qtd', 'Peso Unit.', 'Peso Total', 'Corte', 'Solda', 'Pint/Galv', 'Expedição']
|
||||
];
|
||||
|
||||
// Configurar a tabela
|
||||
autoTable(doc, {
|
||||
head: headers,
|
||||
body: tableData,
|
||||
startY: yPos + 15,
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 2,
|
||||
lineColor: [204, 204, 204],
|
||||
lineWidth: 0.5,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [233, 236, 239],
|
||||
textColor: 0,
|
||||
fontStyle: 'bold',
|
||||
lineColor: [204, 204, 204],
|
||||
lineWidth: 0.5,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [248, 249, 250],
|
||||
},
|
||||
columnStyles: {
|
||||
0: { cellWidth: 25, halign: 'center' }, // OF
|
||||
1: { cellWidth: 20, halign: 'center' }, // Fase
|
||||
2: { cellWidth: 25, halign: 'center' }, // Marca
|
||||
3: { cellWidth: 15, halign: 'center' }, // Qtd
|
||||
4: { cellWidth: 25, halign: 'right' }, // Peso Unit
|
||||
5: { cellWidth: 25, halign: 'right' }, // Peso Total
|
||||
6: { cellWidth: 20, halign: 'center' }, // Corte
|
||||
7: { cellWidth: 20, halign: 'center' }, // Solda
|
||||
8: { cellWidth: 25, halign: 'center' }, // Pint/Galv
|
||||
9: { cellWidth: 25, halign: 'center' }, // Expedição
|
||||
},
|
||||
margin: { left: 20, right: 20 },
|
||||
tableWidth: 'auto',
|
||||
});
|
||||
|
||||
// Rodapé
|
||||
const finalY = (doc as any).lastAutoTable.finalY || yPos + 50;
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(153, 153, 153);
|
||||
doc.text('Relatório gerado pelo sistema', 20, finalY + 20);
|
||||
|
||||
// Salvar o PDF
|
||||
const filename = selectedFase === 'todas'
|
||||
? `relatorio-status-pecas-${selectedOF}-${new Date().toISOString().split('T')[0]}.pdf`
|
||||
: `relatorio-status-pecas-${selectedOF}-fase-${selectedFase}-${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
|
||||
doc.save(filename);
|
||||
|
||||
toast.success(`PDF gerado com sucesso! ${pecasComStatus.length} peças exportadas.`);
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PDF:', error);
|
||||
toast.error('Erro ao gerar PDF');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={generatePDF}
|
||||
disabled={pecasComStatus.length === 0}
|
||||
className="bg-green-600 hover:bg-green-700 text-white flex items-center gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Gerar PDF
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { PecaComStatus } from '@/hooks/useRelatorioPecasProcesso';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface RelatorioPecasProcessoPrintProps {
|
||||
pecasComStatus: PecaComStatus[];
|
||||
estatisticas: {
|
||||
totalPecas: number;
|
||||
pesoTotalCorte: number;
|
||||
pesoTotalSolda: number;
|
||||
pesoTotalPintura: number;
|
||||
pesoTotalExpedicao: number;
|
||||
};
|
||||
selectedOF: string;
|
||||
selectedFase: string;
|
||||
}
|
||||
|
||||
export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintProps> = ({
|
||||
pecasComStatus,
|
||||
estatisticas,
|
||||
selectedOF,
|
||||
selectedFase
|
||||
}) => {
|
||||
const generatePrintDocument = () => {
|
||||
if (pecasComStatus.length === 0) {
|
||||
toast.error('Nenhuma peça disponível para impressão.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Criar template HTML para impressão
|
||||
const printTemplate = document.createElement('div');
|
||||
printTemplate.style.fontFamily = 'Inter, sans-serif';
|
||||
|
||||
const faseText = selectedFase === 'todas' ? '' : ` - Fase ${selectedFase}`;
|
||||
const currentDate = new Date().toLocaleString('pt-BR');
|
||||
|
||||
printTemplate.innerHTML = `
|
||||
<style>
|
||||
@media print {
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: white;
|
||||
}
|
||||
.a4-page {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 1.5cm;
|
||||
background: white;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px 8px;
|
||||
font-size: 9pt;
|
||||
text-align: center;
|
||||
}
|
||||
th {
|
||||
background-color: #e9ecef;
|
||||
font-weight: 600;
|
||||
}
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
.header-info, .summary-info { font-size: 10pt; }
|
||||
tr { page-break-inside: avoid; }
|
||||
thead { display: table-header-group; }
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
.a4-page {
|
||||
width: 21cm;
|
||||
min-height: 29.7cm;
|
||||
padding: 1.5cm;
|
||||
margin: 1cm auto;
|
||||
background: white;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px 8px;
|
||||
font-size: 9pt;
|
||||
text-align: center;
|
||||
}
|
||||
th {
|
||||
background-color: #e9ecef;
|
||||
font-weight: 600;
|
||||
}
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
.header-info, .summary-info { font-size: 10pt; }
|
||||
</style>
|
||||
|
||||
<div class="a4-page">
|
||||
<!-- Cabeçalho -->
|
||||
<header style="margin-bottom: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid #ccc; padding-bottom: 8px;">
|
||||
<div>
|
||||
<h1 style="font-size: 20px; font-weight: bold; color: #1f2937; margin: 0 0 4px 0;">Relatório de Status</h1>
|
||||
<p style="font-size: 14px; color: #6b7280; margin: 0;" class="header-info">Ordem de Fabricação: <span style="font-weight: 600;">${selectedOF}${faseText}</span></p>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<p style="font-size: 14px; color: #6b7280; margin: 0;" class="header-info">Gerado em:</p>
|
||||
<p style="font-size: 14px; color: #9ca3af; margin: 0;" class="header-info">${currentDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Resumo -->
|
||||
<section style="margin-bottom: 16px;">
|
||||
<h2 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #374151;">Resumo por Processo</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; font-size: 12px; color: #6b7280; padding: 8px; border: 1px solid #e5e7eb; border-radius: 8px; background-color: #f9fafb;" class="summary-info">
|
||||
<div><strong>Total de Peças:</strong> <span style="font-family: monospace;">${estatisticas.totalPecas}</span></div>
|
||||
<div><strong>Peso Corte:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalCorte.toFixed(2)} kg</span></div>
|
||||
<div><strong>Peso Solda:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalSolda.toFixed(2)} kg</span></div>
|
||||
<div><strong>Peso Pintura:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalPintura.toFixed(2)} kg</span></div>
|
||||
<div><strong>Peso Expedição:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalExpedicao.toFixed(2)} kg</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tabela -->
|
||||
<main>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>OF</th>
|
||||
<th>Fase</th>
|
||||
<th>Marca</th>
|
||||
<th>Qtd</th>
|
||||
<th>Peso Unit.</th>
|
||||
<th>Peso Total</th>
|
||||
<th>Corte</th>
|
||||
<th>Solda</th>
|
||||
<th>Pint/Galv</th>
|
||||
<th>Expedição</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${pecasComStatus.map(peca => `
|
||||
<tr>
|
||||
<td>${peca.of_number}</td>
|
||||
<td>${peca.etapa_fase}</td>
|
||||
<td>${peca.marca}</td>
|
||||
<td>${peca.quantidade}</td>
|
||||
<td class="text-right">${peca.peso_unitario.toFixed(2)} kg</td>
|
||||
<td class="text-right">${peca.peso_total.toFixed(2)} kg</td>
|
||||
<td>${peca.processos.corte ? 'OK' : ''}</td>
|
||||
<td>${!peca.tem_componentes ? 'S/M' : (peca.processos.solda ? 'OK' : '')}</td>
|
||||
<td>${peca.processos.pintura ? 'OK' : ''}</td>
|
||||
<td>${peca.processos.expedicao ? 'OK' : ''}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
<!-- Rodapé -->
|
||||
<footer style="text-align: center; font-size: 10px; color: #9ca3af; margin-top: 32px; padding-top: 16px; border-top: 1px solid #e5e7eb;">
|
||||
<p>Relatório gerado pelo sistema</p>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Criar nova janela para impressão
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) {
|
||||
toast.error('Não foi possível abrir a janela de impressão. Verifique se o bloqueador de pop-ups está desabilitado.');
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Relatório de Status - OF ${selectedOF}${faseText}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
${printTemplate.innerHTML}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
printWindow.document.close();
|
||||
|
||||
// Aguardar carregamento da fonte antes de imprimir
|
||||
setTimeout(() => {
|
||||
printWindow.print();
|
||||
printWindow.close();
|
||||
}, 500);
|
||||
|
||||
toast.success('Impressão iniciada com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao imprimir:', error);
|
||||
toast.error('Erro ao imprimir relatório');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={generatePrintDocument}
|
||||
disabled={pecasComStatus.length === 0}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Imprimir
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user