🚀 Initial commit: Versão atual do TrackSteel APP

This commit is contained in:
2026-03-18 21:17:53 +00:00
commit bde410c9ad
633 changed files with 108150 additions and 0 deletions
@@ -0,0 +1,433 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { CalendarIcon, Save, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { cn } from '@/lib/utils';
import { CronogramaOf, ProcessoCronograma } from '@/types/cronograma';
import { useCronogramaOperations } from '@/hooks/useCronogramaOperations';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
interface CronogramaFormProps {
cronograma?: CronogramaOf | null;
onClose: () => void;
isOpen: boolean;
}
const processosDefault = [
'Detalhamento',
'Fabricação',
'Pint/Galv',
'Instalação',
'Aceite/DB'
];
export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onClose, isOpen }) => {
const { saveCronograma, getCronogramaPorOf } = useCronogramaOperations();
const [formData, setFormData] = useState({
of_id: '',
gestor_id: '',
revisao: 1,
processos: processosDefault.map((nome, index) => ({
nome_processo: nome,
data_inicio: '',
data_fim: '',
ordem: index + 1
})) as ProcessoCronograma[]
});
const [ofs, setOfs] = useState<any[]>([]);
const [gestores, setGestores] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen) {
loadOfs();
loadGestores();
}
}, [isOpen]);
useEffect(() => {
if (cronograma) {
console.log('Carregando cronograma para edição:', cronograma);
setFormData({
of_id: cronograma.of_id,
gestor_id: cronograma.gestor_id,
revisao: cronograma.revisao,
processos: cronograma.processos.length > 0 ? cronograma.processos.map(p => ({
id: p.id,
nome_processo: p.nome_processo,
data_inicio: p.data_inicio,
data_fim: p.data_fim,
ordem: p.ordem
})) : processosDefault.map((nome, index) => ({
nome_processo: nome,
data_inicio: '',
data_fim: '',
ordem: index + 1
}))
});
}
}, [cronograma]);
const loadOfs = async () => {
try {
const { data, error } = await supabase
.from('ordens_fabricacao')
.select('id, num_of, descritivo')
.order('num_of');
if (error) throw error;
setOfs(data || []);
} catch (error) {
console.error('Erro ao carregar OFs:', error);
}
};
const loadGestores = async () => {
try {
const { data, error } = await supabase
.from('profiles')
.select('id, full_name, email')
.not('full_name', 'is', null)
.order('full_name');
if (error) throw error;
setGestores(data || []);
} catch (error) {
console.error('Erro ao carregar gestores:', error);
}
};
const handleOfChange = async (ofId: string) => {
console.log('OF selecionada:', ofId);
setFormData(prev => ({ ...prev, of_id: ofId }));
// Verificar se já existe cronograma para esta OF
const cronogramaExistente = await getCronogramaPorOf(ofId);
console.log('Cronograma existente encontrado:', cronogramaExistente);
if (cronogramaExistente) {
// Carregar dados do cronograma existente
setFormData({
of_id: ofId,
gestor_id: cronogramaExistente.gestor_id,
revisao: cronogramaExistente.revisao,
processos: cronogramaExistente.processos.length > 0 ? cronogramaExistente.processos.map(p => ({
id: p.id,
nome_processo: p.nome_processo,
data_inicio: p.data_inicio,
data_fim: p.data_fim,
ordem: p.ordem
})) : processosDefault.map((nome, index) => ({
nome_processo: nome,
data_inicio: '',
data_fim: '',
ordem: index + 1
}))
});
toast.info('Cronograma existente carregado para edição');
} else {
// Resetar para valores padrão se não houver cronograma
setFormData(prev => ({
...prev,
gestor_id: '',
revisao: 1,
processos: processosDefault.map((nome, index) => ({
nome_processo: nome,
data_inicio: '',
data_fim: '',
ordem: index + 1
}))
}));
}
};
const handleProcessoChange = (index: number, field: keyof ProcessoCronograma, value: string) => {
const newProcessos = [...formData.processos];
newProcessos[index] = { ...newProcessos[index], [field]: value };
setFormData({ ...formData, processos: newProcessos });
};
const addProcesso = () => {
const newProcesso: ProcessoCronograma = {
nome_processo: '',
data_inicio: '',
data_fim: '',
ordem: formData.processos.length + 1
};
setFormData({
...formData,
processos: [...formData.processos, newProcesso]
});
};
const removeProcesso = (index: number) => {
const newProcessos = formData.processos.filter((_, i) => i !== index);
// Reordenar
newProcessos.forEach((processo, i) => {
processo.ordem = i + 1;
});
setFormData({ ...formData, processos: newProcessos });
};
const moveProcessoUp = (index: number) => {
if (index === 0) return;
const newProcessos = [...formData.processos];
[newProcessos[index], newProcessos[index - 1]] = [newProcessos[index - 1], newProcessos[index]];
// Atualizar ordem
newProcessos.forEach((processo, i) => {
processo.ordem = i + 1;
});
setFormData({ ...formData, processos: newProcessos });
};
const moveProcessoDown = (index: number) => {
if (index === formData.processos.length - 1) return;
const newProcessos = [...formData.processos];
[newProcessos[index], newProcessos[index + 1]] = [newProcessos[index + 1], newProcessos[index]];
// Atualizar ordem
newProcessos.forEach((processo, i) => {
processo.ordem = i + 1;
});
setFormData({ ...formData, processos: newProcessos });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.of_id || !formData.gestor_id) {
toast.error('Por favor, selecione a OF e o gestor responsável');
return;
}
if (formData.processos.some(p => !p.nome_processo || !p.data_inicio || !p.data_fim)) {
toast.error('Por favor, preencha todos os campos dos processos');
return;
}
setLoading(true);
const success = await saveCronograma({
...formData,
id: cronograma?.id
});
setLoading(false);
if (success) {
onClose();
}
};
const DatePicker = ({ date, onDateChange, placeholder }: {
date: string;
onDateChange: (date: string) => void;
placeholder: string;
}) => {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
date ? new Date(date + 'T12:00:00') : undefined
);
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600 text-gray-900 dark:text-gray-100",
!selectedDate && "text-gray-500 dark:text-gray-400"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate ? format(selectedDate, "dd/MM/yyyy", { locale: ptBR }) : placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={(date) => {
if (date) {
setSelectedDate(date);
onDateChange(format(date, 'yyyy-MM-dd'));
}
}}
initialFocus
className="pointer-events-auto p-3"
/>
</PopoverContent>
</Popover>
);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{cronograma ? 'Editar Cronograma' : 'Novo Cronograma'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="of_id">Ordem de Fabricação *</Label>
<Select
value={formData.of_id}
onValueChange={handleOfChange}
>
<SelectTrigger>
<SelectValue placeholder="Selecione a OF" />
</SelectTrigger>
<SelectContent>
{ofs.map((of) => (
<SelectItem key={of.id} value={of.id}>
{of.num_of} - {of.descritivo}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="gestor_id">Gestor Responsável *</Label>
<Select
value={formData.gestor_id}
onValueChange={(value) => setFormData({ ...formData, gestor_id: value })}
>
<SelectTrigger>
<SelectValue placeholder="Selecione o gestor" />
</SelectTrigger>
<SelectContent>
{gestores.map((gestor) => (
<SelectItem key={gestor.id} value={gestor.id}>
{gestor.full_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Processos do Cronograma</h3>
<Button type="button" onClick={addProcesso} size="sm" variant="outline">
<Plus className="w-4 h-4 mr-2" />
Adicionar Processo
</Button>
</div>
<div className="space-y-4">
{formData.processos.map((processo, index) => (
<Card key={index} className="p-4">
<div className="flex items-start gap-4">
{/* Controles de ordenação */}
<div className="flex flex-col gap-1 mt-6">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveProcessoUp(index)}
disabled={index === 0}
className="h-8 w-8 p-0"
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveProcessoDown(index)}
disabled={index === formData.processos.length - 1}
className="h-8 w-8 p-0"
>
<ChevronDown className="w-4 h-4" />
</Button>
</div>
{/* Número de ordem */}
<div className="flex items-center justify-center w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full mt-6">
<span className="text-sm font-semibold text-blue-600 dark:text-blue-300">
{index + 1}
</span>
</div>
{/* Campos do processo */}
<div className="flex-1">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<div className="space-y-2">
<Label>Nome do Processo *</Label>
<Input
value={processo.nome_processo}
onChange={(e) => handleProcessoChange(index, 'nome_processo', e.target.value)}
placeholder="Ex: Detalhamento"
/>
</div>
<div className="space-y-2">
<Label>Data de Início *</Label>
<DatePicker
date={processo.data_inicio}
onDateChange={(date) => handleProcessoChange(index, 'data_inicio', date)}
placeholder="Selecionar data"
/>
</div>
<div className="space-y-2">
<Label>Data de Fim *</Label>
<DatePicker
date={processo.data_fim}
onDateChange={(date) => handleProcessoChange(index, 'data_fim', date)}
placeholder="Selecionar data"
/>
</div>
</div>
</div>
{/* Botão de remoção */}
<div className="mt-6">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeProcesso(index)}
disabled={formData.processos.length === 1}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
<Save className="w-4 h-4 mr-2" />
{loading ? 'Salvando...' : 'Salvar Cronograma'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,185 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { X, Calendar, FileDown } from 'lucide-react';
import { format, differenceInDays, parseISO } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { CronogramaOf } from '@/types/cronograma';
interface CronogramaGanttProps {
cronograma: CronogramaOf;
onClose: () => void;
onExportPDF?: () => void;
}
export const CronogramaGantt: React.FC<CronogramaGanttProps> = ({ cronograma, onClose, onExportPDF }) => {
const cores = [
'bg-blue-500 dark:bg-blue-600',
'bg-green-500 dark:bg-green-600',
'bg-yellow-500 dark:bg-yellow-600',
'bg-purple-500 dark:bg-purple-600',
'bg-red-500 dark:bg-red-600',
'bg-indigo-500 dark:bg-indigo-600',
'bg-pink-500 dark:bg-pink-600',
'bg-gray-500 dark:bg-gray-600'
];
// Calcular o período total do cronograma
const todasAsDatas = cronograma.processos.flatMap(p => [p.data_inicio, p.data_fim]);
const dataInicioTotal = new Date(Math.min(...todasAsDatas.map(d => new Date(d).getTime())));
const dataFimTotal = new Date(Math.max(...todasAsDatas.map(d => new Date(d).getTime())));
const duracaoTotal = differenceInDays(dataFimTotal, dataInicioTotal) + 1;
const calcularPosicaoELargura = (dataInicio: string, dataFim: string) => {
const inicio = parseISO(dataInicio);
const fim = parseISO(dataFim);
const diasDoInicio = differenceInDays(inicio, dataInicioTotal);
const duracaoProcesso = differenceInDays(fim, inicio) + 1;
const left = (diasDoInicio / duracaoTotal) * 100;
const width = (duracaoProcesso / duracaoTotal) * 100;
return { left: `${left}%`, width: `${width}%` };
};
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => {
return differenceInDays(parseISO(dataFim), parseISO(dataInicio)) + 1;
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-6xl max-h-[90vh] overflow-auto bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700">
<CardHeader className="flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700">
<CardTitle className="flex items-center gap-2 text-gray-900 dark:text-gray-100">
<Calendar className="w-5 h-5" />
Cronograma da OF: {cronograma.ordem_fabricacao?.num_of} - Rev. {cronograma.revisao}
</CardTitle>
<div className="flex items-center gap-2">
{onExportPDF && (
<Button onClick={onExportPDF} variant="outline" size="sm" className="border-gray-200 dark:border-gray-600">
<FileDown className="w-4 h-4 mr-2" />
Exportar PDF
</Button>
)}
<Button variant="ghost" size="sm" onClick={onClose} className="hover:bg-gray-100 dark:hover:bg-gray-700">
<X className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6 p-6">
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Gestor Responsável:</strong> {cronograma.gestor_profile?.full_name}
</div>
{/* Tabela de Prazos */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Prazos de Produção da OF</h3>
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-300 dark:border-gray-600">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-gray-900 dark:text-gray-100">Processo</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-gray-900 dark:text-gray-100">Data de Início</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-gray-900 dark:text-gray-100">Data de Fim</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-gray-900 dark:text-gray-100">Dias Corridos</th>
</tr>
</thead>
<tbody>
{cronograma.processos
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
.map((processo, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-700'}>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 font-medium text-gray-900 dark:text-gray-100">
{processo.nome_processo}
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-gray-900 dark:text-gray-100">
{format(parseISO(processo.data_inicio), 'dd/MM/yyyy', { locale: ptBR })}
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-gray-900 dark:text-gray-100">
{format(parseISO(processo.data_fim), 'dd/MM/yyyy', { locale: ptBR })}
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-center text-gray-900 dark:text-gray-100">
{calcularDiasCorridos(processo.data_inicio, processo.data_fim)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Gráfico de Gantt */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Linha do Tempo dos Processos</h3>
{/* Cabeçalho com datas */}
<div className="relative bg-gray-100 dark:bg-gray-700 h-8 rounded border border-gray-200 dark:border-gray-600">
<div className="absolute left-0 top-1 text-xs font-medium px-2 text-gray-700 dark:text-gray-300">
{format(dataInicioTotal, 'dd/MM/yyyy', { locale: ptBR })}
</div>
<div className="absolute right-0 top-1 text-xs font-medium px-2 text-gray-700 dark:text-gray-300">
{format(dataFimTotal, 'dd/MM/yyyy', { locale: ptBR })}
</div>
<div className="absolute left-1/2 top-1 text-xs font-medium transform -translate-x-1/2 text-gray-700 dark:text-gray-300">
{duracaoTotal} dias totais
</div>
</div>
{/* Barras dos processos */}
<div className="space-y-3">
{cronograma.processos
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
.map((processo, index) => {
const posicao = calcularPosicaoELargura(processo.data_inicio, processo.data_fim);
const cor = cores[index % cores.length];
const diasCorridos = calcularDiasCorridos(processo.data_inicio, processo.data_fim);
const dataInicioFormatada = format(parseISO(processo.data_inicio), 'dd/MM', { locale: ptBR });
const dataFimFormatada = format(parseISO(processo.data_fim), 'dd/MM', { locale: ptBR });
return (
<div key={index} className="relative">
<div className="flex items-center mb-1">
<div className="w-32 text-sm font-medium truncate text-gray-900 dark:text-gray-100" title={processo.nome_processo}>
{processo.nome_processo}
</div>
<div className="flex-1 relative h-8 bg-gray-200 dark:bg-gray-600 rounded ml-4">
<div
className={`absolute top-0 h-full ${cor} rounded flex items-center justify-between px-2 text-white text-xs font-medium`}
style={posicao}
title={`${processo.nome_processo}: ${dataInicioFormatada} - ${dataFimFormatada} (${diasCorridos} dias)`}
>
<span className="text-xs">{dataInicioFormatada}</span>
<span className="text-xs font-bold">{diasCorridos}d</span>
<span className="text-xs">{dataFimFormatada}</span>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Legenda */}
<div className="flex flex-wrap gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Legenda:</div>
{cronograma.processos
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
.map((processo, index) => {
const cor = cores[index % cores.length];
return (
<div key={index} className="flex items-center gap-2 text-sm">
<div className={`w-4 h-4 ${cor} rounded`}></div>
<span className="text-gray-900 dark:text-gray-100">{processo.nome_processo}</span>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
};
+293
View File
@@ -0,0 +1,293 @@
import React, { useEffect } from 'react';
import { CronogramaOf } from '@/hooks/useCronogramas';
import { useBrandSettings } from '@/hooks/useBrandSettings';
import jsPDF from 'jspdf';
import { format, differenceInDays, parseISO } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface CronogramaPDFProps {
cronograma: CronogramaOf;
onComplete?: () => void;
}
export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComplete }) => {
const { brandSettings } = useBrandSettings();
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => {
return differenceInDays(parseISO(dataFim), parseISO(dataInicio)) + 1;
};
const gerarPDF = async () => {
const doc = new jsPDF('portrait', 'mm', 'a4');
// Configurações de cores (tons profissionais em cinza)
const corCinzaClaro = [200, 200, 200]; // Cinza claro para cabeçalho
const corCinzaMedio = [150, 150, 150]; // Cinza médio
const corCinzaEscuro = [80, 80, 80]; // Cinza escuro para texto
const corBranco = [255, 255, 255]; // Branco
const pageWidth = doc.internal.pageSize.width;
const pageHeight = doc.internal.pageSize.height;
const margin = 15;
const usableWidth = pageWidth - (margin * 2);
let yPosition = margin;
// CABEÇALHO COMPACTO
// Fundo do cabeçalho - altura reduzida para 25px
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
doc.rect(0, 0, pageWidth, 25, 'F');
// Logo da empresa (tamanho reduzido)
if (brandSettings.logo_url) {
try {
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = brandSettings.logo_url!;
});
// Logo menor - 20x15
const logoWidth = 20;
const logoHeight = 15;
doc.addImage(img, 'PNG', margin, 5, logoWidth, logoHeight);
} catch (error) {
console.log('Erro ao carregar logo:', error);
}
}
// Nome da empresa e título
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text(brandSettings.company_name, brandSettings.logo_url ? margin + 25 : margin, 12);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text('CRONOGRAMA DE PRODUÇÃO', brandSettings.logo_url ? margin + 25 : margin, 18);
yPosition = 35;
// TÍTULO DO CRONOGRAMA
doc.setFillColor(240, 240, 240);
doc.rect(margin, yPosition - 3, usableWidth, 12, 'F');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
const titulo = `OF: ${cronograma.ordem_fabricacao?.num_of} - Revisão: ${cronograma.revisao}`;
doc.text(titulo, margin + 3, yPosition + 3);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text(`Gestor: ${cronograma.gestor_profile?.full_name || 'N/A'}`, margin + 3, yPosition + 8);
yPosition += 18;
// RESUMO EXECUTIVO COMPACTO
const todasAsDatas = cronograma.processos.flatMap(p => [p.data_inicio, p.data_fim]);
const dataInicioTotal = new Date(Math.min(...todasAsDatas.map(d => new Date(d).getTime())));
const dataFimTotal = new Date(Math.max(...todasAsDatas.map(d => new Date(d).getTime())));
const duracaoTotal = differenceInDays(dataFimTotal, dataInicioTotal) + 1;
// Boxes do resumo em linha
const boxWidth = (usableWidth) / 3;
doc.setFillColor(corBranco[0], corBranco[1], corBranco[2]);
doc.setDrawColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
// Box 1
doc.rect(margin, yPosition, boxWidth - 2, 15, 'FD');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text(`${cronograma.processos.length} Processos`, margin + 3, yPosition + 6);
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.text('Total', margin + 3, yPosition + 11);
// Box 2
doc.rect(margin + boxWidth, yPosition, boxWidth - 2, 15, 'FD');
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text(`${duracaoTotal} dias`, margin + boxWidth + 3, yPosition + 6);
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.text('Duração', margin + boxWidth + 3, yPosition + 11);
// Box 3
doc.rect(margin + boxWidth * 2, yPosition, boxWidth - 2, 15, 'FD');
doc.setFontSize(8);
doc.setFont('helvetica', 'bold');
doc.text(`${format(dataInicioTotal, 'dd/MM', { locale: ptBR })} - ${format(dataFimTotal, 'dd/MM', { locale: ptBR })}`, margin + boxWidth * 2 + 3, yPosition + 6);
doc.setFont('helvetica', 'normal');
doc.text('Período', margin + boxWidth * 2 + 3, yPosition + 11);
yPosition += 22;
// TABELA COMPACTA
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
doc.rect(margin, yPosition, usableWidth, 8, 'F');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('CRONOGRAMA DETALHADO', margin + 3, yPosition + 5);
yPosition += 10;
// Cabeçalho da tabela - altura reduzida
const colWidths = [usableWidth * 0.4, usableWidth * 0.2, usableWidth * 0.2, usableWidth * 0.2];
const headers = ['Processo', 'Início', 'Fim', 'Duração'];
doc.setFillColor(245, 245, 245);
doc.rect(margin, yPosition, usableWidth, 7, 'F');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
let xPosition = margin;
headers.forEach((header, index) => {
doc.text(header, xPosition + 2, yPosition + 5);
xPosition += colWidths[index];
});
yPosition += 7;
// Linhas da tabela - altura reduzida em 40% (de 12 para 7.2)
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
cronograma.processos
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
.forEach((processo, index) => {
// Alternância de cores
if (index % 2 === 0) {
doc.setFillColor(250, 250, 250);
doc.rect(margin, yPosition, usableWidth, 7, 'F');
}
xPosition = margin;
const rowData = [
processo.nome_processo.length > 25 ? processo.nome_processo.substring(0, 25) + '...' : processo.nome_processo,
format(parseISO(processo.data_inicio), 'dd/MM', { locale: ptBR }),
format(parseISO(processo.data_fim), 'dd/MM', { locale: ptBR }),
`${calcularDiasCorridos(processo.data_inicio, processo.data_fim)}d`
];
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
rowData.forEach((data, colIndex) => {
doc.text(data, xPosition + 2, yPosition + 5);
xPosition += colWidths[colIndex];
});
yPosition += 7;
});
yPosition += 10;
// GRÁFICO DE GANTT VISUAL MELHORADO
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
doc.rect(margin, yPosition, usableWidth, 8, 'F');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('LINHA DO TEMPO VISUAL', margin + 3, yPosition + 5);
yPosition += 12;
// Escala de tempo
doc.setTextColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
doc.setFontSize(8);
doc.text(format(dataInicioTotal, 'dd/MM/yy', { locale: ptBR }), margin, yPosition - 2);
doc.text(format(dataFimTotal, 'dd/MM/yy', { locale: ptBR }), margin + usableWidth - 20, yPosition - 2);
doc.text(`${duracaoTotal} dias`, margin + usableWidth/2 - 10, yPosition - 2);
// Linha de base
doc.setDrawColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
doc.line(margin, yPosition, margin + usableWidth, yPosition);
yPosition += 3;
// Barras dos processos - área reservada para nomes maior
const cores = [
[52, 152, 219], // Azul
[46, 204, 113], // Verde
[241, 196, 15], // Amarelo
[155, 89, 182], // Roxo
[231, 76, 60], // Vermelho
[230, 126, 34], // Laranja
[26, 188, 156], // Turquesa
[127, 140, 141] // Cinza
];
const nomeAreaWidth = 60; // Área reservada para nomes dos processos
const graficoWidth = usableWidth - nomeAreaWidth - 5;
cronograma.processos
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
.forEach((processo, index) => {
const diasDoInicio = differenceInDays(parseISO(processo.data_inicio), dataInicioTotal);
const duracaoProcesso = calcularDiasCorridos(processo.data_inicio, processo.data_fim);
const barraInicio = margin + nomeAreaWidth + (diasDoInicio / duracaoTotal) * graficoWidth;
const barraLargura = Math.max(2, (duracaoProcesso / duracaoTotal) * graficoWidth);
// Nome do processo na área reservada
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(7);
doc.setFont('helvetica', 'normal');
const nomeProcesso = processo.nome_processo.length > 20 ?
processo.nome_processo.substring(0, 20) + '...' :
processo.nome_processo;
doc.text(nomeProcesso, margin, yPosition + 2);
// Barra colorida
const cor = cores[index % cores.length];
doc.setFillColor(cor[0], cor[1], cor[2]);
doc.rect(barraInicio, yPosition - 1, barraLargura, 6, 'F');
// Duração na barra (se houver espaço)
if (barraLargura > 8) {
doc.setTextColor(255, 255, 255);
doc.setFontSize(6);
doc.setFont('helvetica', 'bold');
doc.text(`${duracaoProcesso}d`, barraInicio + barraLargura/2 - 2, yPosition + 2);
}
yPosition += 8;
});
// RODAPÉ COMPACTO
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(7);
doc.setFont('helvetica', 'normal');
doc.text(`${brandSettings.company_name} - Sistema de Gestão`, margin, pageHeight - 8);
doc.text(`${format(new Date(), 'dd/MM/yyyy HH:mm')}`, pageWidth - 35, pageHeight - 8);
// Download do PDF
const nomeArquivo = `cronograma_${cronograma.ordem_fabricacao?.num_of}_rev${cronograma.revisao}_${format(new Date(), 'ddMMyyyy')}.pdf`;
doc.save(nomeArquivo);
if (onComplete) {
onComplete();
}
};
useEffect(() => {
const timer = setTimeout(() => {
gerarPDF();
}, 100);
return () => clearTimeout(timer);
}, [cronograma, onComplete, brandSettings]);
return null;
};
@@ -0,0 +1,187 @@
import React, { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Edit, Trash2, BarChart3, FileText } from 'lucide-react';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { CronogramaOf } from '@/types/cronograma';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
interface CronogramaTableProps {
cronogramas: CronogramaOf[];
onEdit: (cronograma: CronogramaOf) => void;
onDelete: (cronogramaId: string) => void;
onViewChart: (cronograma: CronogramaOf) => void;
onViewPDF: (cronograma: CronogramaOf) => void;
canEdit?: boolean;
canDelete?: boolean;
}
export const CronogramaTable: React.FC<CronogramaTableProps> = ({
cronogramas,
onEdit,
onDelete,
onViewChart,
onViewPDF,
canEdit = true,
canDelete = true,
}) => {
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => {
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays;
};
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-gray-200 dark:border-gray-700">
<TableHead className="text-gray-900 dark:text-gray-100">OF</TableHead>
<TableHead className="text-gray-900 dark:text-gray-100">Gestor</TableHead>
<TableHead className="text-gray-900 dark:text-gray-100">Revisão</TableHead>
<TableHead className="text-gray-900 dark:text-gray-100">Processos</TableHead>
<TableHead className="text-gray-900 dark:text-gray-100">Peso Total (kg)</TableHead>
<TableHead className="text-gray-900 dark:text-gray-100">Período Total</TableHead>
<TableHead className="text-gray-900 dark:text-gray-100">Dias Corridos</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cronogramas.map((cronograma) => {
const primeiroProcesso = cronograma.processos[0];
const ultimoProcesso = cronograma.processos[cronograma.processos.length - 1];
const dataInicio = primeiroProcesso?.data_inicio;
const dataFim = ultimoProcesso?.data_fim;
const diasCorridos = dataInicio && dataFim ? calcularDiasCorridos(dataInicio, dataFim) : 0;
return (
<TableRow key={cronograma.id} className="border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50">
<TableCell className="font-medium">
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100">{cronograma.ordem_fabricacao?.num_of}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{cronograma.ordem_fabricacao?.descritivo}
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900 dark:text-gray-100">
{cronograma.gestor_profile?.full_name || 'N/A'}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100">Rev. {cronograma.revisao}</Badge>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900 dark:text-gray-100">
{cronograma.processos.length} processo{cronograma.processos.length !== 1 ? 's' : ''}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900 dark:text-gray-100 font-medium">
{cronograma.peso_total ? `${Number(cronograma.peso_total).toLocaleString('pt-BR')} kg` : 'N/A'}
</div>
</TableCell>
<TableCell>
{dataInicio && dataFim ? (
<div className="text-sm">
<div className="text-gray-900 dark:text-gray-100">{format(new Date(dataInicio + 'T00:00:00'), 'dd/MM/yyyy', { locale: ptBR })}</div>
<div className="text-gray-600 dark:text-gray-400">até</div>
<div className="text-gray-900 dark:text-gray-100">{format(new Date(dataFim + 'T00:00:00'), 'dd/MM/yyyy', { locale: ptBR })}</div>
</div>
) : (
<span className="text-gray-500 dark:text-gray-400">-</span>
)}
</TableCell>
<TableCell>
<Badge variant="outline" className="border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100">{diasCorridos} dias</Badge>
</TableCell>
<TableCell>
<div className="flex items-center justify-end space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => onViewChart(cronograma)}
title="Ver gráfico Gantt"
className="border-blue-200 dark:border-blue-600 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-800/30 text-blue-700 dark:text-blue-300"
>
<BarChart3 className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onViewPDF(cronograma)}
title="Gerar PDF"
className="border-green-200 dark:border-green-600 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-800/30 text-green-700 dark:text-green-300"
>
<FileText className="w-4 h-4" />
</Button>
{canEdit && (
<Button
variant="outline"
size="sm"
onClick={() => onEdit(cronograma)}
title="Editar cronograma"
className="border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<Edit className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</Button>
)}
{canDelete && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
title="Remover cronograma"
className="border-red-200 dark:border-red-600 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-800/30 text-red-700 dark:text-red-300"
>
<Trash2 className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-gray-900 dark:text-gray-100">Remover Cronograma</AlertDialogTitle>
<AlertDialogDescription className="text-gray-600 dark:text-gray-400">
Tem certeza que deseja remover o cronograma da OF {cronograma.ordem_fabricacao?.num_of}?
Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-gray-200 dark:border-gray-600 text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700">Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => cronograma.id && onDelete(cronograma.id)}
className="bg-red-600 hover:bg-red-700 text-white"
>
Remover
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
};