Files
btpxpress-frontend/components/phases/PhasesTable.tsx
2025-10-01 01:39:07 +00:00

639 lines
26 KiB
TypeScript

'use client';
import React, { useState, useRef, useEffect } from 'react';
import { DataTable, DataTableExpandedRows } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Badge } from 'primereact/badge';
import { Toast } from 'primereact/toast';
import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
import { Menu } from 'primereact/menu';
import { TabView, TabPanel } from 'primereact/tabview';
import {
ActionButtonGroup,
ViewButton,
EditButton,
DeleteButton,
StartButton,
CompleteButton,
ProgressButton,
BudgetPlanButton,
BudgetTrackButton
} from '../ui/ActionButton';
import { PhaseChantier, StatutPhase } from '../../types/btp-extended';
import phaseService from '../../services/phaseService';
import materielPhaseService from '../../services/materielPhaseService';
import fournisseurPhaseService from '../../services/fournisseurPhaseService';
export interface PhasesTableProps {
// Données
phases: PhaseChantier[];
loading?: boolean;
chantierId?: string;
// Affichage
showStats?: boolean;
showChantierColumn?: boolean;
showSubPhases?: boolean;
showBudget?: boolean;
showExpansion?: boolean;
showGlobalFilter?: boolean;
// Actions disponibles
actions?: Array<'view' | 'edit' | 'delete' | 'start' | 'complete' | 'progress' | 'budget-plan' | 'budget-track' | 'all'>;
// Callbacks
onRefresh?: () => void;
onPhaseSelect?: (phase: PhaseChantier) => void;
onPhaseEdit?: (phase: PhaseChantier) => void;
onPhaseDelete?: (phaseId: string) => void;
onPhaseStart?: (phaseId: string) => void;
onPhaseProgress?: (phase: PhaseChantier) => void;
onPhaseBudgetPlan?: (phase: PhaseChantier) => void;
onPhaseBudgetTrack?: (phase: PhaseChantier) => void;
onSubPhaseAdd?: (parentPhase: PhaseChantier) => void;
// Configuration
rows?: number;
emptyMessage?: string;
className?: string;
globalFilter?: string;
}
const PhasesTable: React.FC<PhasesTableProps> = ({
phases,
loading = false,
chantierId,
showStats = false,
showChantierColumn = false,
showSubPhases = true,
showBudget = true,
showExpansion = true,
showGlobalFilter = false,
actions = ['all'],
onRefresh,
onPhaseSelect,
onPhaseEdit,
onPhaseDelete,
onPhaseStart,
onPhaseProgress,
onPhaseBudgetPlan,
onPhaseBudgetTrack,
onSubPhaseAdd,
rows = 15,
emptyMessage = "Aucune phase trouvée",
className = "p-datatable-lg",
globalFilter = ''
}) => {
const toast = useRef<Toast>(null);
const [expandedRows, setExpandedRows] = useState<DataTableExpandedRows | undefined>(undefined);
const [materielsPhase, setMaterielsPhase] = useState<any[]>([]);
const [fournisseursPhase, setFournisseursPhase] = useState<any[]>([]);
// Déterminer quelles actions afficher
const shouldShowAction = (action: string) => {
return actions.includes('all') || actions.includes(action as any);
};
// Templates de colonnes
const statutBodyTemplate = (rowData: PhaseChantier) => {
const severityMap: Record<string, any> = {
'PLANIFIEE': 'secondary',
'EN_ATTENTE': 'warning',
'EN_COURS': 'info',
'SUSPENDUE': 'warning',
'TERMINEE': 'success',
'ANNULEE': 'danger'
};
return <Tag value={rowData.statut} severity={severityMap[rowData.statut]} />;
};
const avancementBodyTemplate = (rowData: PhaseChantier) => {
const progress = rowData.pourcentageAvancement || 0;
const color = progress === 100 ? 'var(--green-500)' : progress >= 50 ? 'var(--blue-500)' : 'var(--orange-500)';
return (
<div className="flex align-items-center gap-2">
<ProgressBar
value={progress}
style={{ width: '100px', height: '8px' }}
color={color}
showValue={false}
/>
<span className="text-sm font-semibold">{progress}%</span>
</div>
);
};
const dateBodyTemplate = (rowData: PhaseChantier, field: keyof PhaseChantier) => {
const date = rowData[field] as string;
if (!date) return <span className="text-color-secondary">-</span>;
const dateObj = new Date(date);
const isOverdue = field === 'dateFinPrevue' && dateObj < new Date() && rowData.statut !== 'TERMINEE';
return (
<span className={isOverdue ? 'text-red-500 font-semibold' : ''}>
{dateObj.toLocaleDateString('fr-FR')}
</span>
);
};
const prioriteBodyTemplate = (rowData: PhaseChantier) => {
const severityMap: Record<string, any> = {
'FAIBLE': 'secondary',
'MOYENNE': 'info',
'ELEVEE': 'warning',
'CRITIQUE': 'danger'
};
return rowData.priorite ? <Tag value={rowData.priorite} severity={severityMap[rowData.priorite]} /> : null;
};
const budgetBodyTemplate = (rowData: PhaseChantier) => {
return (
<span className="text-900 font-semibold">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(rowData.budgetPrevu || 0)}
</span>
);
};
const coutReelBodyTemplate = (rowData: PhaseChantier) => {
const cout = rowData.coutReel || 0;
const budget = rowData.budgetPrevu || 0;
const depassement = cout > budget;
return (
<span className={depassement ? 'text-red-500 font-semibold' : 'text-900'}>
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(cout)}
{depassement && (
<i className="pi pi-exclamation-triangle text-red-500 ml-2" title="Dépassement budgétaire"></i>
)}
</span>
);
};
const chantierBodyTemplate = (rowData: PhaseChantier) => {
return rowData.chantier?.nom || '-';
};
const phaseNameBodyTemplate = (rowData: PhaseChantier) => {
return (
<div className="flex align-items-center gap-2">
{showSubPhases && (
<Badge
value={rowData.ordreExecution || 0}
className="bg-primary text-primary-50"
style={{ minWidth: '1.5rem' }}
/>
)}
<i className="pi pi-sitemap text-sm text-color-secondary"></i>
<div className="flex flex-column flex-1">
<span className="font-semibold text-color">
{rowData.nom}
</span>
{rowData.description && (
<small className="text-color-secondary text-xs mt-1">
{rowData.description.length > 60
? rowData.description.substring(0, 60) + '...'
: rowData.description
}
</small>
)}
</div>
{rowData.critique && (
<Badge
value="Critique"
severity="danger"
className="text-xs"
/>
)}
</div>
);
};
// Chargement du matériel et fournisseurs pour l'expansion
const loadMaterielPhase = async (phaseId: string) => {
try {
const materiels = await materielPhaseService.getByPhase(phaseId);
setMaterielsPhase(materiels);
} catch (error) {
console.error('Erreur lors du chargement du matériel:', error);
}
};
const loadFournisseursPhase = async (phaseId: string) => {
try {
const fournisseurs = await fournisseurPhaseService.getByPhase(phaseId);
setFournisseursPhase(fournisseurs);
} catch (error) {
console.error('Erreur lors du chargement des fournisseurs:', error);
}
};
// Template d'expansion
const rowExpansionTemplate = (data: PhaseChantier) => {
if (data.phaseParent || !showSubPhases) return null;
const sousPhases = phases.filter(p => p.phaseParent === data.id);
return (
<div className="p-4 bg-surface-50">
<TabView>
<TabPanel header="Sous-phases" leftIcon="pi pi-sitemap">
<div className="flex justify-content-between align-items-center mb-4">
<h6 className="m-0 text-color">
Sous-phases de "{data.nom}" ({sousPhases.length})
</h6>
{onSubPhaseAdd && (
<Button
label="Ajouter une sous-phase"
icon="pi pi-plus"
className="p-button-text p-button-rounded p-button-success p-button-sm"
onClick={() => onSubPhaseAdd(data)}
/>
)}
</div>
{sousPhases.length > 0 ? (
<DataTable
value={sousPhases}
size="small"
className="p-datatable-sm"
emptyMessage="Aucune sous-phase"
>
<Column
field="nom"
header="Sous-phase"
style={{ minWidth: '15rem' }}
body={(rowData) => (
<div className="flex align-items-center gap-2">
<Badge
value={rowData.ordreExecution || 0}
className="bg-surface-100 text-surface-700 text-xs"
style={{ minWidth: '1.2rem', fontSize: '0.7rem' }}
/>
<i className="pi pi-minus text-xs text-color-secondary"></i>
<span className="font-semibold flex-1">{rowData.nom}</span>
{rowData.critique && (
<Tag value="Critique" severity="danger" className="text-xs" />
)}
</div>
)}
/>
<Column
field="statut"
header="Statut"
style={{ width: '8rem' }}
body={statutBodyTemplate}
/>
<Column
field="pourcentageAvancement"
header="Avancement"
style={{ width: '10rem' }}
body={avancementBodyTemplate}
/>
<Column
field="dateDebutPrevue"
header="Début prévu"
style={{ width: '10rem' }}
body={(rowData) => dateBodyTemplate(rowData, 'dateDebutPrevue')}
/>
<Column
field="dateFinPrevue"
header="Fin prévue"
style={{ width: '10rem' }}
body={(rowData) => dateBodyTemplate(rowData, 'dateFinPrevue')}
/>
{showBudget && (
<>
<Column
field="budgetPrevu"
header="Budget"
style={{ width: '8rem' }}
body={budgetBodyTemplate}
/>
<Column
field="coutReel"
header="Coût réel"
style={{ width: '8rem' }}
body={coutReelBodyTemplate}
/>
</>
)}
<Column
header="Actions"
style={{ width: '10rem' }}
body={(rowData) => (
<ActionButtonGroup>
{shouldShowAction('view') && onPhaseSelect && (
<ViewButton
tooltip="Voir détails"
onClick={() => onPhaseSelect(rowData)}
/>
)}
{shouldShowAction('edit') && onPhaseEdit && (
<EditButton
tooltip="Modifier"
onClick={() => onPhaseEdit(rowData)}
/>
)}
{shouldShowAction('start') && onPhaseStart && (
<StartButton
tooltip="Démarrer"
disabled={rowData.statut !== 'PLANIFIEE'}
onClick={() => onPhaseStart(rowData.id!)}
/>
)}
{shouldShowAction('delete') && onPhaseDelete && (
<DeleteButton
tooltip="Supprimer"
onClick={() => onPhaseDelete(rowData.id!)}
/>
)}
</ActionButtonGroup>
)}
/>
</DataTable>
) : (
<div className="text-center p-4">
<i className="pi pi-info-circle text-4xl text-color-secondary mb-3"></i>
<p className="text-color-secondary m-0">
Aucune sous-phase définie pour cette phase.
</p>
</div>
)}
</TabPanel>
<TabPanel header="Matériel" leftIcon="pi pi-wrench">
<Button
label="Charger le matériel"
icon="pi pi-refresh"
onClick={() => loadMaterielPhase(data.id!)}
className="mb-3 p-button-text p-button-rounded"
/>
<DataTable
value={materielsPhase}
size="small"
emptyMessage="Aucun matériel assigné"
>
<Column field="nom" header="Matériel" />
<Column field="quantite" header="Quantité" />
<Column field="statut" header="Statut" />
</DataTable>
</TabPanel>
<TabPanel header="Fournisseurs" leftIcon="pi pi-users">
<Button
label="Charger les fournisseurs"
icon="pi pi-refresh"
onClick={() => loadFournisseursPhase(data.id!)}
className="mb-3 p-button-text p-button-rounded"
/>
<DataTable
value={fournisseursPhase}
size="small"
emptyMessage="Aucun fournisseur recommandé"
>
<Column field="nom" header="Fournisseur" />
<Column field="specialite" header="Spécialité" />
<Column field="notation" header="Note" />
</DataTable>
</TabPanel>
</TabView>
</div>
);
};
// Template des actions principales
const actionBodyTemplate = (rowData: PhaseChantier) => {
const handleDelete = () => {
confirmDialog({
message: `Êtes-vous sûr de vouloir supprimer la phase "${rowData.nom}" ?`,
header: 'Confirmer la suppression',
icon: 'pi pi-exclamation-triangle',
acceptClassName: 'p-button-danger',
acceptLabel: 'Supprimer',
rejectLabel: 'Annuler',
accept: async () => {
try {
await phaseService.delete(rowData.id!);
if (onRefresh) onRefresh();
toast.current?.show({
severity: 'success',
summary: 'Suppression réussie',
detail: 'La phase a été supprimée',
life: 3000
});
} catch (error) {
console.error('Erreur:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de supprimer la phase',
life: 5000
});
}
}
});
};
return (
<ActionButtonGroup>
{shouldShowAction('view') && onPhaseSelect && (
<ViewButton
tooltip="Voir détails"
onClick={() => onPhaseSelect(rowData)}
/>
)}
{shouldShowAction('edit') && onPhaseEdit && (
<EditButton
tooltip="Modifier"
onClick={() => onPhaseEdit(rowData)}
/>
)}
{shouldShowAction('start') && onPhaseStart && (
<StartButton
tooltip="Démarrer"
disabled={rowData.statut !== 'PLANIFIEE'}
onClick={() => onPhaseStart(rowData.id!)}
/>
)}
{shouldShowAction('progress') && onPhaseProgress && (
<ProgressButton
tooltip="Mettre à jour avancement"
onClick={() => onPhaseProgress(rowData)}
/>
)}
{shouldShowAction('budget-plan') && onPhaseBudgetPlan && (
<BudgetPlanButton
tooltip="Planifier le budget"
onClick={() => onPhaseBudgetPlan(rowData)}
/>
)}
{shouldShowAction('budget-track') && onPhaseBudgetTrack && (
<BudgetTrackButton
tooltip="Suivi des dépenses"
onClick={() => onPhaseBudgetTrack(rowData)}
/>
)}
{shouldShowAction('delete') && (onPhaseDelete || true) && (
<DeleteButton
tooltip="Supprimer"
onClick={() => onPhaseDelete ? onPhaseDelete(rowData.id!) : handleDelete()}
/>
)}
</ActionButtonGroup>
);
};
// Style des lignes
const phaseRowClassName = (rowData: PhaseChantier) => {
let className = '';
if (rowData.phaseParent) {
className += ' bg-surface-card border-left-4 border-surface-300';
} else {
className += ' bg-surface-ground border-left-4 border-primary font-semibold';
}
if (rowData.critique) {
className += ' border-red-500';
}
return className;
};
// Filtrer les phases principales seulement si subPhases est activé
const displayPhases = showSubPhases ? phases.filter(p => !p.phaseParent) : phases;
return (
<>
<Toast ref={toast} />
<ConfirmDialog />
<DataTable
value={displayPhases}
loading={loading}
paginator
rows={rows}
globalFilter={showGlobalFilter ? globalFilter : undefined}
emptyMessage={emptyMessage}
className={className}
dataKey="id"
expandedRows={showExpansion ? expandedRows : undefined}
onRowToggle={showExpansion ? (e) => setExpandedRows(e.data) : undefined}
rowExpansionTemplate={showExpansion ? rowExpansionTemplate : undefined}
rowClassName={phaseRowClassName}
>
{showExpansion && showSubPhases && <Column expander style={{ width: '3rem' }} />}
<Column
field="nom"
header="Phase"
sortable
style={{ minWidth: '20rem' }}
body={phaseNameBodyTemplate}
/>
{showChantierColumn && (
<Column
field="chantier.nom"
header="Chantier"
sortable
style={{ width: '15rem' }}
body={chantierBodyTemplate}
/>
)}
<Column
field="statut"
header="Statut"
body={statutBodyTemplate}
sortable
style={{ width: '10rem' }}
/>
<Column
field="priorite"
header="Priorité"
body={prioriteBodyTemplate}
sortable
style={{ width: '8rem' }}
/>
<Column
field="pourcentageAvancement"
header="Avancement"
body={avancementBodyTemplate}
style={{ width: '12rem' }}
/>
<Column
field="dateDebutPrevue"
header="Début prévu"
body={(rowData) => dateBodyTemplate(rowData, 'dateDebutPrevue')}
sortable
style={{ width: '10rem' }}
/>
<Column
field="dateFinPrevue"
header="Fin prévue"
body={(rowData) => dateBodyTemplate(rowData, 'dateFinPrevue')}
sortable
style={{ width: '10rem' }}
/>
<Column
field="dureeEstimeeHeures"
header="Durée (h)"
sortable
style={{ width: '8rem' }}
body={(rowData) => (
<span>{rowData.dureeEstimeeHeures || 0}h</span>
)}
/>
{showBudget && (
<>
<Column
field="budgetPrevu"
header="Budget prévu"
sortable
style={{ width: '10rem' }}
body={budgetBodyTemplate}
/>
<Column
field="coutReel"
header="Coût réel"
sortable
style={{ width: '10rem' }}
body={coutReelBodyTemplate}
/>
</>
)}
<Column
header="Actions"
style={{ width: '12rem' }}
body={actionBodyTemplate}
/>
</DataTable>
</>
);
};
export default PhasesTable;