639 lines
26 KiB
TypeScript
Executable File
639 lines
26 KiB
TypeScript
Executable File
'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; |