chore: synchronize local fixes to gitea

This commit is contained in:
2026-03-14 00:25:56 +00:00
commit b4ffe72b3e
393 changed files with 71657 additions and 0 deletions

View File

@@ -0,0 +1,584 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../services/api';
import { Button } from '../components/Button';
import { Plus, Search, Pencil, Trash2, Activity, Box, CheckCircle2, History, Printer } from 'lucide-react';
import { format } from 'date-fns';
import { useAuth } from '../context/useAuth';
import { useToast } from '../hooks/useToast';
import { MobileList } from '../components/MobileList';
import { CreateProjectModal } from '../components/modals/CreateProjectModal';
import { useOrganization } from '@clerk/clerk-react';
import { useSystemSettings } from '../context/SystemSettingsContext';
import { Modal } from '../components/Modal';
import { ConfirmModal } from '../components/ConfirmModal';
import { ColorBubble } from '../components/ColorBubble';
import type { Project, Inspection } from '../types';
import { AnalyticalReport } from '../components/reports/AnalyticalReport';
import { GeneralProjectReport } from '../components/reports/GeneralProjectReport';
export const ProjectList: React.FC = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [inspections, setInspections] = useState<Inspection[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<Project | undefined>(undefined);
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
description: string;
type: 'danger' | 'warning' | 'info';
onConfirm: () => void;
}>({
isOpen: false,
title: '',
description: '',
type: 'info',
onConfirm: () => { }
});
const [statsModal, setStatsModal] = useState<{ isOpen: boolean; title: string; type: 'alerts' | 'inspections' | null }>({
isOpen: false,
title: '',
type: null
});
const [searchTerm, setSearchTerm] = useState('');
const [viewStatus, setViewStatus] = useState<'active' | 'archived'>('active');
const [printingProject, setPrintingProject] = useState<Project | null>(null);
const [isPrinting, setIsPrinting] = useState(false);
const [isPrintingGeneral, setIsPrintingGeneral] = useState(false);
const navigate = useNavigate();
const { appUser } = useAuth();
const { showToast } = useToast();
const { organization } = useOrganization();
const { settings } = useSystemSettings();
const logoUrl = settings?.appLogoUrl || organization?.imageUrl;
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
const fetchProjects = useCallback(async () => {
try {
setLoading(true);
const [projRes, inspRes] = await Promise.all([
api.get(`/projects?status=${viewStatus}`),
api.get('/inspections')
]);
setProjects(projRes.data);
setInspections(inspRes.data);
} catch (error) {
console.error('Error fetching data:', error);
showToast('Não foi possível carregar os dados.', 'error');
} finally {
setLoading(false);
}
}, [viewStatus, showToast]);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
const handleEdit = (project: Project) => {
if (viewStatus === 'archived') return;
setSelectedProject(project);
setIsModalOpen(true);
};
const handleDelete = async (id: string) => {
const project = projects.find(p => p.id === id);
setConfirmModal({
isOpen: true,
title: 'Excluir Projeto',
description: `Tem certeza que deseja excluir o projeto "${project?.name}"? Todos os dados vinculados serão perdidos permanentemente.`,
type: 'danger',
onConfirm: async () => {
try {
await api.delete(`/projects/${id}`);
fetchProjects();
showToast('O projeto foi removido com sucesso.', 'success');
} catch (error) {
console.error('Error deleting project:', error);
showToast('Não foi possível excluir o projeto.', 'error');
}
setConfirmModal(prev => ({ ...prev, isOpen: false }));
}
});
};
const handlePrint = async (projectId: string) => {
try {
setIsPrinting(true);
const response = await api.get(`/projects/${projectId}`);
setPrintingProject(response.data);
// Wait for state to update and layout to render
setTimeout(() => {
window.print();
setIsPrinting(false);
setPrintingProject(null);
}, 500);
} catch (error) {
console.error('Error fetching project for print:', error);
showToast('Erro ao gerar relatório.', 'error');
setIsPrinting(false);
}
};
const handlePrintGeneral = () => {
setIsPrintingGeneral(true);
setTimeout(() => {
window.print();
setIsPrintingGeneral(false);
}, 500);
};
const handleArchive = async (project: Project) => {
const isArchiving = viewStatus === 'active';
setConfirmModal({
isOpen: true,
title: isArchiving ? 'Concluir Obras' : 'Reativar Projeto',
description: `Deseja ${isArchiving ? 'arquivar' : 'reativar'} o projeto "${project.name}"?`,
type: 'info',
onConfirm: async () => {
try {
await api.patch(`/projects/${project.id}/archive`);
showToast(`O projeto ${project.name} foi movido com sucesso.`, 'success');
fetchProjects();
} catch (error) {
console.error('Error archiving project:', error);
showToast('Não foi possível alterar o status do projeto.', 'error');
}
setConfirmModal(prev => ({ ...prev, isOpen: false }));
}
});
};
const filteredProjects = projects.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.client.toLowerCase().includes(searchTerm.toLowerCase())
);
const columns = [
{
header: 'Obra / Projeto',
accessor: (item: Project) => (
<div
className="flex flex-col cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => navigate(`/project/${item.id}`)}
>
<span className="text-text-main font-black text-sm uppercase leading-tight line-clamp-1">{item.name}</span>
<span className="text-[10px] text-text-muted font-bold uppercase tracking-wider">{item.client}</span>
<span className="text-[9px] text-text-secondary font-bold tracking-tight">
Gestor: <span className="text-text-main">{item.technician || 'Não informado'}</span>
</span>
</div>
),
className: 'min-w-[150px]'
},
{
header: 'Evolução',
accessor: (item: Project) => {
const projectInspections = inspections.filter(i => i.projectId === item.id);
const sumWeight = projectInspections.reduce((acc, curr) => acc + (curr.weightKg || 0), 0);
const totalWeight = item.weightKg || 0;
const percentage = totalWeight > 0 ? Math.min(Math.round((sumWeight / totalWeight) * 100), 100) : 0;
return (
<div className="flex items-center justify-center">
<div className="relative w-10 h-10 flex items-center justify-center">
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
<circle
cx="18"
cy="18"
r="15.9155"
fill="transparent"
stroke="currentColor"
strokeWidth="3"
className="text-border/20"
/>
<circle
cx="18"
cy="18"
r="15.9155"
fill="transparent"
stroke="currentColor"
strokeWidth="3"
strokeDasharray={`${percentage} 100`}
className="text-primary transition-all duration-1000"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-black text-text-main">
{percentage}%
</span>
</div>
</div>
);
},
className: 'hidden sm:table-cell w-[80px]'
},
{
header: 'Cronograma',
accessor: (item: Project) => (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-text-muted font-black uppercase w-10">Início:</span>
<span className="bg-orange-500/10 text-orange-500 px-1.5 py-0.5 rounded text-[10px] font-black">
{item.startDate ? format(new Date(item.startDate), 'dd/MM/yy') : '--/--/--'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-text-muted font-black uppercase w-10">Término:</span>
<span className="bg-orange-950/20 text-orange-400 px-1.5 py-0.5 rounded text-[10px] font-black border border-orange-500/10">
{item.endDate ? format(new Date(item.endDate), 'dd/MM/yy') : '--/--/--'}
</span>
</div>
</div>
),
className: 'hidden md:table-cell w-[110px]'
},
{
header: 'Peso(kgf)',
accessor: (item: Project) => (
<div className="flex flex-col items-center">
<span className="text-text-main font-black text-sm">{item.weightKg ? item.weightKg.toLocaleString('pt-BR') : '0'}</span>
<span className="text-[8px] text-text-muted font-bold uppercase tracking-tighter">Est. Total</span>
</div>
),
className: 'w-[90px]'
},
{
header: 'Tinta',
accessor: (item: Project) => {
const schemes = item.paintingSchemes || [];
if (schemes.length === 0) return <span className="text-text-muted font-bold text-xs">---</span>;
return (
<div className="flex flex-col gap-1.5 py-1">
{schemes.slice(0, 2).map((scheme, idx) => (
<div key={idx} className="flex flex-col max-w-[140px]">
<span className="text-text-main font-bold text-[10px] truncate uppercase leading-none" title={scheme.name}>
{scheme.name}
</span>
<span className="text-[7px] text-text-muted font-black uppercase tracking-tighter opacity-70">
{scheme.coat || 'Demão'}
</span>
</div>
))}
{schemes.length > 2 && (
<span className="text-[7px] text-primary font-black uppercase">+ {schemes.length - 2} demãos</span>
)}
</div>
);
},
className: 'hidden xl:table-cell w-[130px]'
},
{
header: 'Cor',
accessor: (item: Project) => {
const schemes = item.paintingSchemes || [];
if (schemes.length === 0) return <div className="w-4 h-4 rounded-full border border-border/20 opacity-20 border-dashed" />;
return (
<div className="flex flex-col gap-1.5 py-1">
{schemes.slice(0, 2).map((scheme, idx) => (
<div key={idx} className="flex items-center gap-2 h-[18px]">
<span className="text-[9px] text-text-secondary font-bold uppercase truncate max-w-[60px]" title={scheme.color}>
{scheme.color || '-'}
</span>
<ColorBubble colorHex={scheme.colorHex} className="w-3.5 h-3.5" />
</div>
))}
</div>
);
},
className: 'hidden xl:table-cell w-[80px]'
},
{
header: 'Ações',
accessor: (item: Project) => (
<div className="flex items-center justify-end gap-1.5 min-w-[120px]">
<button
onClick={(e) => { e.stopPropagation(); handlePrint(item.id); }}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors flex items-center justify-center"
title="Gerar Relatório Analítico"
disabled={isPrinting}
>
<Printer size={18} />
</button>
{isAdmin && (
<button
onClick={(e) => { e.stopPropagation(); handleArchive(item); }}
className={`p-2 rounded-lg transition-colors ${viewStatus === 'active'
? 'text-green-500 hover:bg-green-500/10'
: 'text-indigo-500 hover:bg-indigo-500/10'
}`}
title={viewStatus === 'active' ? 'Concluir e Arquivar' : 'Reativar Projeto'}
>
{viewStatus === 'active' ? <CheckCircle2 size={16} /> : <History size={16} />}
</button>
)}
{isAdmin && viewStatus === 'active' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Editar"
>
<Pencil size={18} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(item.id); }}
className="p-2 text-text-muted hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
title="Excluir"
>
<Trash2 size={18} />
</button>
</>
)}
</div>
)
}
];
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-10 animate-in fade-in duration-700">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-sm">
<Activity className="w-6 h-6" />
</div>
<div>
<h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">
{viewStatus === 'active' ? 'Gestão de Obras' : 'Obras Arquivadas'}
</h1>
<p className="text-sm text-text-muted font-medium tracking-wide">
{viewStatus === 'active'
? 'Monitoramento e controle de esquemas industriais'
: 'Histórico de projetos concluídos e arquivados'}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="secondary"
onClick={() => setViewStatus(viewStatus === 'active' ? 'archived' : 'active')}
className={`shadow-sm ${viewStatus === 'archived' ? 'bg-indigo-500/10 text-indigo-500 border-indigo-500/20' : ''}`}
>
{viewStatus === 'active' ? <Box className="w-5 h-5 mr-2" /> : <Activity className="w-5 h-5 mr-2" />}
{viewStatus === 'active' ? 'Ver Arquivados' : 'Ver Ativos'}
</Button>
<div className="relative hidden lg:block group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder="Buscar obras..."
className="h-12 w-48 bg-surface-soft border border-border/40 rounded-xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button
variant="secondary"
onClick={handlePrintGeneral}
className="shadow-sm"
disabled={isPrintingGeneral || projects.length === 0}
>
<Printer className="w-5 h-5 mr-2" />
Relatório Geral
</Button>
{isAdmin && viewStatus === 'active' && (
<Button onClick={() => { setSelectedProject(undefined); setIsModalOpen(true); }} size="md" className="shadow-primary/30">
<Plus className="w-5 h-5 mr-2" />
Novo Projeto
</Button>
)}
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[
{
label: viewStatus === 'active' ? 'Projetos Ativos' : 'Projetos Arquivados',
value: projects.length,
color: viewStatus === 'active' ? 'text-primary' : 'text-indigo-500',
bg: 'bg-primary/5',
onClick: () => { },
interactive: false
},
{
label: 'Inspeções Realizadas',
value: inspections.length,
color: 'text-success',
bg: 'bg-success/5',
onClick: () => setStatsModal({ isOpen: true, title: 'Resumo de Inspeções', type: 'inspections' }),
interactive: true
},
{
label: 'Conformidade',
value: inspections.length > 0
? `${((inspections.filter(i => i.appearance !== 'rejected').length / inspections.length) * 100).toFixed(1)}%`
: '100%',
color: 'text-blue-500',
bg: 'bg-blue-500/5',
onClick: () => { },
interactive: false
},
{
label: 'Alertas Pendentes',
value: inspections.filter(i => i.appearance === 'rejected' || i.defects).length,
color: 'text-error',
bg: 'bg-error/5',
onClick: () => setStatsModal({ isOpen: true, title: 'Resumo de Alertas', type: 'alerts' }),
interactive: true
}
].map((stat, i) => (
<div
key={i}
onClick={stat.onClick}
className={`p-6 bg-surface border border-border/40 rounded-2xl shadow-sm group transition-all ${stat.interactive ? 'cursor-pointer hover:border-primary/40 hover:shadow-md active:scale-95' : ''}`}
>
<span className="text-[10px] font-black text-text-muted uppercase tracking-[0.2em]">{stat.label}</span>
<div className="mt-2 flex items-baseline gap-2">
<span className={`text-3xl font-black ${stat.color}`}>{stat.value}</span>
<span className="text-[10px] text-text-muted font-bold">TOTAL</span>
</div>
</div>
))}
</div>
{/* Project Table/List */}
<div className="bg-surface rounded-[32px] border border-border/40 shadow-soft p-2">
<MobileList
data={filteredProjects}
columns={columns}
keyExtractor={(item) => item.id}
onItemClick={(item) => navigate(`/project/${item.id}`)}
titleAccessor="name"
subtitleAccessor="client"
/>
{projects.length === 0 && (
<div className="text-center py-24">
<div className="mx-auto h-20 w-20 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/20 mb-6">
{viewStatus === 'active' ? <Search className="w-10 h-10" /> : <Box className="w-10 h-10" />}
</div>
<h3 className="text-xl font-bold text-text-main">
{viewStatus === 'active' ? 'Nenhuma obra encontrada' : 'Nenhuma obra arquivada'}
</h3>
<p className="mt-2 text-text-muted font-medium">
{viewStatus === 'active'
? 'Inicie o monitoramento criando seu primeiro projeto.'
: 'Ainda não existem projetos concluídos no histórico.'}
</p>
</div>
)}
</div>
<CreateProjectModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setSelectedProject(undefined);
}}
onSuccess={fetchProjects}
initialData={selectedProject}
/>
<Modal
isOpen={statsModal.isOpen}
onClose={() => setStatsModal({ ...statsModal, isOpen: false })}
title={statsModal.title}
>
<div className="space-y-4">
{statsModal.type === 'alerts' && (
inspections.filter(i => i.appearance === 'rejected' || i.defects).map(insp => {
const project = projects.find(p => p.id === insp.projectId);
return (
<div
key={insp.id}
className="p-4 rounded-xl bg-error/5 border border-error/20 cursor-pointer hover:bg-error/10 transition-all active:scale-[0.98]"
onClick={() => navigate(`/project/${insp.projectId}`, { state: { activeTab: 'inspection' } })}
>
<div className="flex justify-between items-start mb-2">
<span className="font-bold text-text-main text-sm uppercase">{project?.name || 'Projeto Desconhecido'}</span>
<span className="text-[10px] font-black text-error border border-error/30 px-1 rounded uppercase">CRÍTICO</span>
</div>
<p className="text-sm text-text-secondary font-medium italic">"{insp.defects || 'Defeito não especificado'}"</p>
<div className="mt-3 flex justify-between items-center text-[10px] text-text-muted font-bold uppercase">
<span>Inspetor: {insp.inspector || '--'}</span>
<span>Data: {insp.date ? format(new Date(insp.date), 'dd/MM/yy') : '--'}</span>
</div>
</div>
);
})
)}
{statsModal.type === 'inspections' && (
inspections.slice(0, 10).map(insp => {
const project = projects.find(p => p.id === insp.projectId);
return (
<div
key={insp.id}
className="p-4 rounded-xl bg-surface-soft border border-border/40 flex justify-between items-center cursor-pointer hover:bg-surface-hover transition-all active:scale-[0.98]"
onClick={() => navigate(`/project/${insp.projectId}`, { state: { activeTab: 'inspection' } })}
>
<div>
<span className="block font-bold text-text-main text-xs uppercase">{project?.name || '---'}</span>
<span className="text-[10px] text-text-muted font-medium uppercase">{insp.pieceDescription || 'Geral'}</span>
</div>
<div className="text-right">
<span className={`text-[10px] font-black px-2 py-0.5 rounded uppercase ${insp.appearance === 'rejected' ? 'bg-error/10 text-error' : 'bg-success/10 text-success'}`}>
{insp.appearance === 'rejected' ? 'REJEITADO' : 'APROVADO'}
</span>
<span className="block text-[10px] text-text-muted mt-1">{insp.date ? format(new Date(insp.date), 'dd/MM/yy') : '--'}</span>
</div>
</div>
);
})
)}
</div>
</Modal>
<ConfirmModal
isOpen={confirmModal.isOpen}
onClose={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={confirmModal.onConfirm}
title={confirmModal.title}
description={confirmModal.description}
type={confirmModal.type}
/>
{printingProject && (
<AnalyticalReport project={printingProject} logoUrl={logoUrl} />
)}
{isPrintingGeneral && (
<GeneralProjectReport
projects={filteredProjects}
inspections={inspections}
logoUrl={logoUrl}
title={viewStatus === 'active' ? 'Relatório de Obras Ativas' : 'Relatório de Obras Arquivadas'}
/>
)}
</div>
);
};