-> Refonte du système de GESTION DES ROULEMENTS de matériels roulants -> Optimisation rendue BDD
985 lines
42 KiB
JavaScript
985 lines
42 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import NavPanel from '../../components/nav-panel/NavPanel';
|
|
import { getAllHoraires, formatHeure, updateHoraireComposition } from '../../api/horaires';
|
|
import { createPerturbation } from '../../api/perturbations';
|
|
import { getAllGares } from '../../api/gares';
|
|
import { getAllMateriels } from '../../api/materielsRoulants';
|
|
import {
|
|
CAUSES_PERTURBATION,
|
|
buildArretFromHoraire,
|
|
recomputePropagation,
|
|
buildPayload,
|
|
ArretCard,
|
|
JoursPerturbationEditor,
|
|
ParcoursPreview,
|
|
doesHoraireCirculateOnDate,
|
|
} from './perturbationsUtils';
|
|
import { canPerform, getCurrentUser, getScopedRegionId } from '../../utils/userUtils';
|
|
import './Perturbations.css';
|
|
|
|
// ── Utilitaires d'attribution et d'impacts ─────────────────────────────────
|
|
const timeToMinutes = (t) => {
|
|
if (!t || typeof t !== 'string') return null;
|
|
const [h, m] = t.split(':').map(Number);
|
|
if (isNaN(h) || isNaN(m)) return null;
|
|
return h * 60 + m;
|
|
};
|
|
|
|
const isWithinTwoHours = (t1, t2) => {
|
|
const m1 = timeToMinutes(t1);
|
|
const m2 = timeToMinutes(t2);
|
|
if (m1 === null || m2 === null) return false;
|
|
let diff = m2 - m1;
|
|
if (diff < 0) diff += 24 * 60;
|
|
return diff >= 0 && diff <= 120;
|
|
};
|
|
|
|
const getMaterielIds = (horaire) => {
|
|
if (!horaire) return [];
|
|
if (Array.isArray(horaire.composition_train)) {
|
|
return horaire.composition_train.map(item => item.materiel_id).filter(Boolean);
|
|
}
|
|
return [];
|
|
};
|
|
|
|
export default function CreerPerturbation() {
|
|
const currentUser = getCurrentUser();
|
|
const canCreatePerturbations = canPerform(currentUser, 'perturbations', 'create');
|
|
|
|
// ── Navigation ────────────────────────────────────────────────────────────
|
|
const [currentStep, setCurrentStep] = useState('horaire');
|
|
|
|
// ── Horaires ──────────────────────────────────────────────────────────────
|
|
const [horaires, setHoraires] = useState([]);
|
|
const [gares, setGares] = useState([]);
|
|
const [loadingHoraires, setLoadingHoraires] = useState(false);
|
|
|
|
const [searchDate, setSearchDate] = useState(new Date().toISOString().split('T')[0]);
|
|
const [searchMode, setSearchMode] = useState('train');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filteredHoraires, setFilteredHoraires] = useState([]);
|
|
const [showResults, setShowResults] = useState(false);
|
|
|
|
const [selectedHoraires, setSelectedHoraires] = useState([]);
|
|
|
|
// ── Formulaire perturbation ───────────────────────────────────────────────
|
|
const [cause, setCause] = useState('incident_technique');
|
|
const [causeDetails, setCauseDetails] = useState('');
|
|
const [sillonSubstitutionId, setSillonSubstitutionId] = useState('');
|
|
const [joursPerturbation, setJoursPerturbation] = useState([{ type: 'jour', date: '', dateDebut: '', dateFin: '' }]);
|
|
const [actif, setActif] = useState(true);
|
|
const [notes, setNotes] = useState('');
|
|
const [materiels, setMateriels] = useState([]);
|
|
const [assigningMateriel, setAssigningMateriel] = useState(false);
|
|
|
|
// ── Arrêts ────────────────────────────────────────────────────────────────
|
|
const [perturbationsArretsMap, setPerturbationsArretsMap] = useState({});
|
|
const [openTrains, setOpenTrains] = useState({});
|
|
const [openStops, setOpenStops] = useState({});
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
const [scopedRegionId, setScopedRegionId] = useState(null);
|
|
|
|
// ── Chargement des horaires ───────────────────────────────────────────────
|
|
useEffect(() => {
|
|
const regionId = getScopedRegionId(getCurrentUser());
|
|
setScopedRegionId(regionId || null);
|
|
setLoadingHoraires(true);
|
|
getAllHoraires(regionId)
|
|
.then(({ data, error }) => {
|
|
if (!error && data) { setHoraires(data); setFilteredHoraires(data); }
|
|
else { setHoraires([]); setFilteredHoraires([]); }
|
|
})
|
|
.catch(() => { setHoraires([]); setFilteredHoraires([]); })
|
|
.finally(() => setLoadingHoraires(false));
|
|
|
|
getAllGares(regionId).then(({ data }) => { if (data) setGares(data); });
|
|
getAllMateriels().then(({ data }) => { if (data) setMateriels(data); });
|
|
}, []);
|
|
|
|
// ── Recherche ─────────────────────────────────────────────────────────────
|
|
const handleSearch = useCallback(() => {
|
|
const q = (searchQuery || '').toLowerCase().trim();
|
|
let filtered = horaires.filter(h => doesHoraireCirculateOnDate(h, searchDate));
|
|
|
|
if (searchMode === 'train') {
|
|
filtered = filtered.filter(h =>
|
|
[h.numero_train, h.type_train, h.gare_depart_nom, h.gare_arrivee_nom, h.ligne_nom]
|
|
.map(f => (f || '').toString().toLowerCase())
|
|
.some(f => f.includes(q))
|
|
);
|
|
} else {
|
|
// mode ligne
|
|
filtered = filtered.filter(h =>
|
|
(h.ligne_nom || '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
|
|
filtered.sort((a, b) => {
|
|
const aNum = a.numero_train || '';
|
|
const bNum = b.numero_train || '';
|
|
return aNum.toString().localeCompare(bNum.toString(), undefined, { numeric: true });
|
|
});
|
|
|
|
setFilteredHoraires(filtered);
|
|
setShowResults(true);
|
|
}, [searchQuery, horaires, searchDate, searchMode]);
|
|
|
|
const handleToggleSelectHoraire = useCallback((h, isSelected) => {
|
|
setSelectedHoraires(prev => {
|
|
if (isSelected) return [...prev, h];
|
|
return prev.filter(item => item.id !== h.id);
|
|
});
|
|
setPerturbationsArretsMap(prev => {
|
|
const next = { ...prev };
|
|
if (isSelected) {
|
|
next[h.id] = buildArretFromHoraire(h);
|
|
} else {
|
|
delete next[h.id];
|
|
}
|
|
return next;
|
|
});
|
|
setOpenTrains(prev => ({ ...prev, [h.id]: true }));
|
|
}, []);
|
|
|
|
const handleSelectSingleHoraire = useCallback((h) => {
|
|
setSelectedHoraires([h]);
|
|
setSearchQuery(`${h.numero_train} - ${h.gare_depart_nom} vers ${h.gare_arrivee_nom}`);
|
|
setShowResults(false);
|
|
setPerturbationsArretsMap({ [h.id]: buildArretFromHoraire(h) });
|
|
setOpenTrains({ [h.id]: true });
|
|
setOpenStops({});
|
|
}, []);
|
|
|
|
const handleClearSearch = useCallback(() => {
|
|
setSearchQuery('');
|
|
setFilteredHoraires(horaires);
|
|
setShowResults(false);
|
|
setSelectedHoraires([]);
|
|
setPerturbationsArretsMap({});
|
|
setOpenTrains({});
|
|
setOpenStops({});
|
|
}, [horaires]);
|
|
|
|
// ── Gestion des perturbations par arrêt ───────────────────────────────────
|
|
const toggleTrainAccordion = useCallback((hId) => {
|
|
setOpenTrains(p => ({ ...p, [hId]: !p[hId] }));
|
|
}, []);
|
|
|
|
const toggleStopAccordion = useCallback((hId, i) => {
|
|
setOpenStops(p => {
|
|
const current = p[hId] || {};
|
|
return { ...p, [hId]: { ...current, [i]: !current[i] } };
|
|
});
|
|
}, []);
|
|
|
|
const handleToggleType = useCallback((hId, index, typeValue, checked) => {
|
|
setPerturbationsArretsMap(prev => {
|
|
const nextMap = { ...prev };
|
|
const nextArr = [...nextMap[hId]];
|
|
const a = { ...nextArr[index] };
|
|
let types = [...(a.types_perturbation || [])];
|
|
|
|
if (checked) {
|
|
if (!types.includes(typeValue)) types.push(typeValue);
|
|
if (typeValue === 'retard') {
|
|
a.manual_retard = true;
|
|
a.retard_minutes = Number(a.retard_minutes) > 0 ? a.retard_minutes : 15;
|
|
}
|
|
if (typeValue === 'reduction_retard') {
|
|
a.manual_reduction = true;
|
|
a.reduction_retard_minutes = Number(a.reduction_retard_minutes) > 0 ? a.reduction_retard_minutes : 5;
|
|
}
|
|
if (typeValue === 'arret_prolonge') {
|
|
a.manual_arret_prolonge = true;
|
|
a.arret_prolonge_minutes = Number(a.arret_prolonge_minutes) > 0 ? a.arret_prolonge_minutes : 5;
|
|
}
|
|
if (typeValue === 'modification_parcours') { a.is_nouveau_terminus = false; a.is_nouvelle_origine = false; }
|
|
} else {
|
|
types = types.filter(t => t !== typeValue);
|
|
if (typeValue === 'retard') { a.manual_retard = false; a.retard_minutes = 0; }
|
|
if (typeValue === 'reduction_retard') { a.manual_reduction = false; a.reduction_retard_minutes = 0; }
|
|
if (typeValue === 'arret_prolonge') { a.manual_arret_prolonge = false; a.arret_prolonge_minutes = 0; }
|
|
if (typeValue === 'modification_parcours') { a.is_nouveau_terminus = false; a.is_nouvelle_origine = false; }
|
|
}
|
|
|
|
a.types_perturbation = types;
|
|
nextArr[index] = a;
|
|
nextMap[hId] = recomputePropagation(nextArr);
|
|
return nextMap;
|
|
});
|
|
}, []);
|
|
|
|
const handleArretChange = useCallback((hId, index, field, value) => {
|
|
setPerturbationsArretsMap(prev => {
|
|
const nextMap = { ...prev };
|
|
const nextArr = [...nextMap[hId]];
|
|
const a = { ...nextArr[index], [field]: value };
|
|
if (field === 'retard_minutes') a.manual_retard = true;
|
|
if (field === 'reduction_retard_minutes') a.manual_reduction = true;
|
|
if (field === 'arret_prolonge_minutes') a.manual_arret_prolonge = true;
|
|
nextArr[index] = a;
|
|
nextMap[hId] = recomputePropagation(nextArr);
|
|
return nextMap;
|
|
});
|
|
}, []);
|
|
|
|
const handleSetNouveauTerminus = useCallback((hId, index) => {
|
|
setPerturbationsArretsMap(prev => {
|
|
const nextMap = { ...prev };
|
|
const nextArr = nextMap[hId].map((a, i) => {
|
|
const types = [...(a.types_perturbation || [])];
|
|
if (i === index) {
|
|
if (!types.includes('modification_parcours')) types.push('modification_parcours');
|
|
return { ...a, types_perturbation: types, is_nouveau_terminus: true, is_nouvelle_origine: false };
|
|
}
|
|
return { ...a, is_nouveau_terminus: false };
|
|
});
|
|
nextMap[hId] = recomputePropagation(nextArr);
|
|
return nextMap;
|
|
});
|
|
}, []);
|
|
|
|
const handleSetNouvelleOrigine = useCallback((hId, index) => {
|
|
setPerturbationsArretsMap(prev => {
|
|
const nextMap = { ...prev };
|
|
const nextArr = nextMap[hId].map((a, i) => {
|
|
const types = [...(a.types_perturbation || [])];
|
|
if (i === index) {
|
|
if (!types.includes('modification_parcours')) types.push('modification_parcours');
|
|
return { ...a, types_perturbation: types, is_nouvelle_origine: true, is_nouveau_terminus: false };
|
|
}
|
|
return { ...a, is_nouvelle_origine: false };
|
|
});
|
|
nextMap[hId] = recomputePropagation(nextArr);
|
|
return nextMap;
|
|
});
|
|
}, []);
|
|
|
|
const handleAddStopAfter = useCallback((hId, index) => {
|
|
setPerturbationsArretsMap(prev => {
|
|
const nextMap = { ...prev };
|
|
const nextArr = [...nextMap[hId]];
|
|
const newStop = {
|
|
gare_id: null,
|
|
gare_nom: '',
|
|
heure_arrivee: '',
|
|
heure_depart: '',
|
|
types_perturbation: ['modification_parcours'],
|
|
type_perturbation: 'modification_parcours',
|
|
is_added_manually: true,
|
|
retard_minutes: 0,
|
|
reduction_retard_minutes: 0,
|
|
retard_cumule: 0,
|
|
cause_arret: '',
|
|
is_nouveau_terminus: false,
|
|
is_nouvelle_origine: false,
|
|
manual_retard: false,
|
|
manual_reduction: false,
|
|
manual_arret_prolonge: false,
|
|
};
|
|
nextArr.splice(index + 1, 0, newStop);
|
|
nextMap[hId] = recomputePropagation(nextArr);
|
|
return nextMap;
|
|
});
|
|
setOpenStops(p => {
|
|
const current = p[hId] || {};
|
|
return { ...p, [hId]: { ...current, [index + 1]: true } };
|
|
});
|
|
}, []);
|
|
|
|
const handleDeleteStop = useCallback((hId, index) => {
|
|
setPerturbationsArretsMap(prev => {
|
|
const nextMap = { ...prev };
|
|
const nextArr = nextMap[hId].filter((_, i) => i !== index);
|
|
nextMap[hId] = recomputePropagation(nextArr);
|
|
return nextMap;
|
|
});
|
|
}, []);
|
|
|
|
// ── Gestion du Matériel Roulant ───────────────────────────────────────────
|
|
const handleAssignNewMaterial = async (horaireTarget, newMatId, sourceHoraireId) => {
|
|
if (!horaireTarget || !newMatId) return;
|
|
setAssigningMateriel(true);
|
|
try {
|
|
const selectedMat = materiels.find(m => m.id === newMatId);
|
|
if (!selectedMat) throw new Error("Matériel introuvable");
|
|
|
|
const newComp = [
|
|
{
|
|
materiel_id: selectedMat.id,
|
|
materiel_nom: selectedMat.nom,
|
|
materiel_nom_technique: selectedMat.nom_technique,
|
|
materiel_image_url: selectedMat.image_url,
|
|
capacite: selectedMat.capacite,
|
|
type_train: selectedMat.type_train,
|
|
position: 1
|
|
}
|
|
];
|
|
|
|
const { error } = await updateHoraireComposition(horaireTarget.id, newComp);
|
|
if (error) throw error;
|
|
|
|
setHoraires(prev => prev.map(h => h.id === horaireTarget.id ? { ...h, composition_train: newComp } : h));
|
|
if (horaireTarget.id === sourceHoraireId) {
|
|
setSelectedHoraires(prev => prev.map(h => h.id === sourceHoraireId ? { ...h, composition_train: newComp } : h));
|
|
}
|
|
alert(`Matériel ${selectedMat.nom} (${selectedMat.nom_technique}) affecté au train ${horaireTarget.numero_train}.`);
|
|
} catch (err) {
|
|
alert("Erreur lors de l'affectation du nouveau matériel : " + (err.message || 'Erreur'));
|
|
} finally {
|
|
setAssigningMateriel(false);
|
|
}
|
|
};
|
|
|
|
const getImpactedHoraires = (baseHoraire) => {
|
|
if (!baseHoraire) return [];
|
|
const matIds = getMaterielIds(baseHoraire);
|
|
if (matIds.length === 0) return [];
|
|
|
|
return horaires.filter(h => {
|
|
if (h.id === baseHoraire.id) return false;
|
|
const otherMatIds = getMaterielIds(h);
|
|
const sharesMaterial = matIds.some(id => otherMatIds.includes(id));
|
|
if (!sharesMaterial) return false;
|
|
|
|
return isWithinTwoHours(baseHoraire.heure_depart, h.heure_depart);
|
|
});
|
|
};
|
|
|
|
// ── Soumission ────────────────────────────────────────────────────────────
|
|
const handleCreate = async () => {
|
|
if (!canCreatePerturbations) {
|
|
alert("Vous n'avez pas les droits pour créer une perturbation.");
|
|
return;
|
|
}
|
|
if (selectedHoraires.length === 0) {
|
|
alert('Veuillez sélectionner au moins un horaire.');
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
for (const h of selectedHoraires) {
|
|
const arrets = perturbationsArretsMap[h.id] || [];
|
|
const { payload, modificationsParcours } = buildPayload({
|
|
selectedHoraire: h,
|
|
cause,
|
|
causeDetails,
|
|
sillonSubstitutionId,
|
|
joursPerturbation,
|
|
actif,
|
|
notes,
|
|
perturbationsArrets: arrets,
|
|
scopedRegionId,
|
|
});
|
|
|
|
const { error } = await createPerturbation(payload, modificationsParcours);
|
|
if (error) {
|
|
alert(`Erreur lors de la création pour le train ${h.numero_train} : ` + (error.message || 'Erreur'));
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
}
|
|
window.location.href = '/perturbations';
|
|
} catch (err) {
|
|
alert("Une erreur inattendue s'est produite lors de la sauvegarde.");
|
|
console.error(err);
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
window.location.href = '/perturbations';
|
|
};
|
|
|
|
// ── Étape 1 : Sélection des trains ────────────────────────────────────────
|
|
const renderStepHoraire = () => (
|
|
<div className="step-container">
|
|
<div className="step-header mb-4">
|
|
<h2>1. Sélection des circulations ferroviaires</h2>
|
|
<p className="text-muted">Recherchez et sélectionnez le ou les trains impactés par l'incident.</p>
|
|
</div>
|
|
|
|
<wcs-card className="wcs-card-form mb-4">
|
|
<wcs-card-body>
|
|
<div className="d-flex flex-wrap gap-3 mb-3">
|
|
<div style={{ minWidth: '220px', flex: 1 }}>
|
|
<wcs-form-field>
|
|
<label htmlFor="search-date">Date de circulation</label>
|
|
<input
|
|
id="search-date"
|
|
type="date"
|
|
className="form-control"
|
|
value={searchDate}
|
|
onChange={e => setSearchDate(e.target.value)}
|
|
/>
|
|
</wcs-form-field>
|
|
</div>
|
|
<div style={{ minWidth: '220px', flex: 1 }}>
|
|
<wcs-form-field>
|
|
<label htmlFor="search-mode">Mode de recherche</label>
|
|
<select
|
|
id="search-mode"
|
|
className="form-control"
|
|
value={searchMode}
|
|
onChange={e => { setSearchMode(e.target.value); setSelectedHoraires([]); setPerturbationsArretsMap({}); }}
|
|
>
|
|
<option value="train">Par Numéro de train / Gare</option>
|
|
<option value="ligne">Par Ligne commerciale entière</option>
|
|
</select>
|
|
</wcs-form-field>
|
|
</div>
|
|
</div>
|
|
|
|
{loadingHoraires ? (
|
|
<div className="text-center p-4">
|
|
<wcs-spinner mode="border" />
|
|
<p className="mt-2 text-muted">Chargement des circulations...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<form onSubmit={e => { e.preventDefault(); handleSearch(); }} className="d-flex gap-2">
|
|
<input
|
|
type="text"
|
|
className="form-control flex-grow-1"
|
|
placeholder={searchMode === 'train' ? "Ex: 842100, Dijon, Lyon..." : "Nom de la ligne commerciale..."}
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
onFocus={() => setShowResults(true)}
|
|
/>
|
|
<wcs-button type="submit" className="wcs-primary" onClick={handleSearch}>
|
|
<wcs-mat-icon icon="search" size="s" class="mr-1" />
|
|
<span>Rechercher</span>
|
|
</wcs-button>
|
|
{(searchQuery || selectedHoraires.length > 0) && (
|
|
<wcs-button type="button" mode="stroked" className="wcs-secondary" onClick={handleClearSearch}>
|
|
<wcs-mat-icon icon="clear" size="s" />
|
|
</wcs-button>
|
|
)}
|
|
</form>
|
|
{showResults && searchQuery && (
|
|
<div className="mt-2 small text-muted">
|
|
<wcs-badge shape="pill" className="wcs-info mr-2">
|
|
{filteredHoraires.length} circulation{filteredHoraires.length !== 1 ? 's' : ''}
|
|
</wcs-badge>
|
|
trouvée{filteredHoraires.length !== 1 ? 's' : ''} pour le {new Date(searchDate).toLocaleDateString('fr-FR')}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</wcs-card-body>
|
|
</wcs-card>
|
|
|
|
{!loadingHoraires && showResults && (
|
|
<div className="horaire-results mb-4">
|
|
<div className="d-flex flex-column gap-2" style={{ maxHeight: '380px', overflowY: 'auto' }}>
|
|
{filteredHoraires.slice(0, 50).map(h => {
|
|
const isSelected = selectedHoraires.some(sh => sh.id === h.id);
|
|
return (
|
|
<div
|
|
key={h.id}
|
|
className={`horaire-result-card p-3 rounded border d-flex align-items-center justify-content-between cursor-pointer ${isSelected ? 'border-primary bg-primary-soft' : 'bg-white'}`}
|
|
onClick={() => searchMode === 'ligne' ? handleToggleSelectHoraire(h, !isSelected) : handleSelectSingleHoraire(h)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<div className="d-flex align-items-center gap-3">
|
|
{searchMode === 'ligne' && (
|
|
<wcs-mat-icon icon={isSelected ? "check_box" : "check_box_outline_blank"} size="m" class={isSelected ? "text-primary" : "text-muted"} />
|
|
)}
|
|
<div>
|
|
<div className="d-flex align-items-center gap-2 mb-1">
|
|
<strong>Train {h.numero_train}</strong>
|
|
{h.type_train && <wcs-badge shape="pill" className="wcs-info">{h.type_train}</wcs-badge>}
|
|
{h.ligne_nom && <wcs-badge shape="pill" className="wcs-secondary">{h.ligne_nom}</wcs-badge>}
|
|
</div>
|
|
<div className="d-flex align-items-center gap-2 text-muted small">
|
|
<span>{h.gare_depart_nom}</span>
|
|
<span className="badge badge-light">{formatHeure(h.heure_depart)}</span>
|
|
<wcs-mat-icon icon="arrow_forward" size="s" />
|
|
<span>{h.gare_arrivee_nom}</span>
|
|
<span className="badge badge-light">{formatHeure(h.heure_arrivee)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<wcs-badge className={isSelected ? 'wcs-success' : 'wcs-secondary'} shape="pill">
|
|
<wcs-mat-icon icon={isSelected ? 'check' : 'add'} size="s" class="mr-1" />
|
|
<span>{isSelected ? 'Sélectionné' : 'Choisir'}</span>
|
|
</wcs-badge>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{filteredHoraires.length === 0 && (
|
|
<div className="text-center p-4 bg-light rounded text-muted">
|
|
Aucun horaire trouvé pour cette date.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedHoraires.length > 0 && (
|
|
<wcs-card className="selected-horaires-summary mb-4">
|
|
<wcs-card-body>
|
|
<div className="d-flex justify-content-between align-items-center mb-2">
|
|
<h4 className="m-0 d-flex align-items-center gap-2">
|
|
<wcs-mat-icon icon="check_circle" size="s" class="text-success" />
|
|
<span>{selectedHoraires.length} circulation{selectedHoraires.length > 1 ? 's' : ''} sélectionnée{selectedHoraires.length > 1 ? 's' : ''}</span>
|
|
</h4>
|
|
<wcs-badge shape="pill" className="wcs-success">Prêt pour étape 2</wcs-badge>
|
|
</div>
|
|
<div className="d-flex flex-wrap gap-2">
|
|
{selectedHoraires.map(h => (
|
|
<span key={h.id} className="badge badge-secondary p-2 d-inline-flex align-items-center gap-1">
|
|
<strong>Train {h.numero_train}</strong> ({h.gare_depart_nom} → {h.gare_arrivee_nom})
|
|
</span>
|
|
))}
|
|
</div>
|
|
</wcs-card-body>
|
|
</wcs-card>
|
|
)}
|
|
|
|
<div className="step-actions d-flex justify-content-between align-items-center mt-4">
|
|
<wcs-button className="wcs-secondary" mode="stroked" onClick={handleCancel}>Annuler</wcs-button>
|
|
<wcs-button
|
|
className="wcs-primary"
|
|
onClick={() => selectedHoraires.length > 0 && setCurrentStep('perturbation')}
|
|
disabled={selectedHoraires.length === 0}
|
|
>
|
|
<span>Étape suivante</span>
|
|
<wcs-mat-icon icon="arrow_forward" size="s" class="ml-1" />
|
|
</wcs-button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ── Étape 2 : Configuration de la perturbation ────────────────────────────
|
|
const renderStepPerturbation = () => (
|
|
<div className="step-container">
|
|
<div className="step-header mb-4">
|
|
<h2>2. Configuration de l'incident & Parcours</h2>
|
|
<p className="text-muted">Définissez la cause de l'incident, les retards par arrêt et les éventuelles substitutions.</p>
|
|
</div>
|
|
|
|
{/* Parcours des trains avec Accordéon */}
|
|
<wcs-card className="mb-4">
|
|
<wcs-card-body>
|
|
<div className="d-flex align-items-center gap-2 mb-3">
|
|
<wcs-mat-icon icon="alt_route" size="m" class="text-primary"></wcs-mat-icon>
|
|
<div>
|
|
<h3 className="m-0 font-weight-bold">Parcours {selectedHoraires.length > 1 ? 'des trains' : 'du train'}</h3>
|
|
<small className="text-muted">Cliquez sur un train pour configurer les retards et arrêts supprimés</small>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedHoraires.map(h => (
|
|
<div key={h.id} className="mb-3 border rounded overflow-hidden">
|
|
<div
|
|
className="p-3 bg-light d-flex justify-content-between align-items-center cursor-pointer"
|
|
onClick={() => toggleTrainAccordion(h.id)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<div className="d-flex align-items-center gap-2">
|
|
<wcs-mat-icon icon="train" size="s" class="text-primary" />
|
|
<strong>Train {h.numero_train}</strong>
|
|
<span className="text-muted">({h.gare_depart_nom} → {h.gare_arrivee_nom})</span>
|
|
</div>
|
|
<wcs-mat-icon icon={openTrains[h.id] ? 'expand_less' : 'expand_more'} size="s" />
|
|
</div>
|
|
|
|
{openTrains[h.id] && (
|
|
<div className="p-3 bg-white">
|
|
{perturbationsArretsMap[h.id]?.map((arret, i) => (
|
|
<ArretCard
|
|
key={i}
|
|
arret={arret}
|
|
index={i}
|
|
isOpen={openStops[h.id]?.[i] || false}
|
|
onToggle={(idx) => toggleStopAccordion(h.id, idx)}
|
|
onToggleType={(idx, val, checked) => handleToggleType(h.id, idx, val, checked)}
|
|
onArretChange={(idx, field, val) => handleArretChange(h.id, idx, field, val)}
|
|
onNouveauTerminus={(idx) => handleSetNouveauTerminus(h.id, idx)}
|
|
onNouvelleOrigine={(idx) => handleSetNouvelleOrigine(h.id, idx)}
|
|
onAddStopAfter={(idx) => handleAddStopAfter(h.id, idx)}
|
|
onDeleteStop={(idx) => handleDeleteStop(h.id, idx)}
|
|
garesList={gares}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</wcs-card-body>
|
|
</wcs-card>
|
|
|
|
{/* Cause globale */}
|
|
<wcs-card className="mb-4">
|
|
<wcs-card-body>
|
|
<div className="d-flex align-items-center gap-2 mb-3">
|
|
<wcs-mat-icon icon="report_problem" size="m" class="text-warning"></wcs-mat-icon>
|
|
<div>
|
|
<h3 className="m-0 font-weight-bold">Cause globale de l'incident</h3>
|
|
<small className="text-muted">Motif officiel transmis à l'information voyageurs</small>
|
|
</div>
|
|
</div>
|
|
|
|
<wcs-form-field class="mb-3">
|
|
<label htmlFor="cause-select">Cause principale *</label>
|
|
<select
|
|
id="cause-select"
|
|
value={cause}
|
|
onChange={e => setCause(e.target.value)}
|
|
className="form-control"
|
|
>
|
|
{CAUSES_PERTURBATION.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
|
</select>
|
|
</wcs-form-field>
|
|
|
|
<wcs-form-field>
|
|
<label htmlFor="cause-details">Détails complémentaires</label>
|
|
<textarea
|
|
id="cause-details"
|
|
placeholder="Précisions sur la cause, voie impactée, consignes régulation..."
|
|
value={causeDetails}
|
|
onChange={e => setCauseDetails(e.target.value)}
|
|
rows="3"
|
|
className="form-control"
|
|
/>
|
|
</wcs-form-field>
|
|
</wcs-card-body>
|
|
</wcs-card>
|
|
|
|
{/* Modification de Matériel */}
|
|
{selectedHoraires.length === 1 && (
|
|
<wcs-card className="mb-4">
|
|
<wcs-card-body>
|
|
<div className="d-flex align-items-center gap-2 mb-3">
|
|
<wcs-mat-icon icon="directions_railway" size="m" class="text-primary"></wcs-mat-icon>
|
|
<div>
|
|
<h3 className="m-0 font-weight-bold">Matériel Roulant & Substitution</h3>
|
|
<small className="text-muted">Réassignation d'une autre rame en cas d'avarie matérielle</small>
|
|
</div>
|
|
</div>
|
|
|
|
{assigningMateriel ? (
|
|
<div className="text-center p-3">
|
|
<wcs-spinner mode="border" />
|
|
<p className="mt-2 text-muted">Affectation en cours...</p>
|
|
</div>
|
|
) : (
|
|
<div className="p-3 bg-light rounded border">
|
|
<div className="mb-3 d-flex align-items-center gap-2">
|
|
<span className="font-weight-bold">Matériel actuel :</span>
|
|
{selectedHoraires[0].composition_train && selectedHoraires[0].composition_train.length > 0 ? (
|
|
<wcs-badge shape="pill" className="wcs-info">
|
|
{selectedHoraires[0].composition_train.map(c => `${c.materiel_nom} (${c.materiel_nom_technique})`).join(', ')}
|
|
</wcs-badge>
|
|
) : (
|
|
<span className="text-muted font-italic">Aucun matériel attribué</span>
|
|
)}
|
|
</div>
|
|
|
|
<wcs-form-field>
|
|
<label htmlFor="change-mr">Attribuer une autre rame de remplacement</label>
|
|
<select
|
|
id="change-mr"
|
|
className="form-control"
|
|
value=""
|
|
onChange={(e) => handleAssignNewMaterial(selectedHoraires[0], e.target.value, selectedHoraires[0].id)}
|
|
>
|
|
<option value="" disabled>Sélectionner un matériel de remplacement...</option>
|
|
{materiels
|
|
.filter(m => !getMaterielIds(selectedHoraires[0]).includes(m.id))
|
|
.map(m => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.nom} - {m.nom_technique} (S/N: {m.numero_serie}, Capacité: {m.capacite || 'N/A'})
|
|
</option>
|
|
))
|
|
}
|
|
</select>
|
|
</wcs-form-field>
|
|
</div>
|
|
)}
|
|
</wcs-card-body>
|
|
</wcs-card>
|
|
)}
|
|
|
|
{/* Horaires Impactés */}
|
|
{selectedHoraires.length === 1 && (
|
|
<wcs-card className="mb-4">
|
|
<wcs-card-body>
|
|
<div className="d-flex align-items-center gap-2 mb-2">
|
|
<wcs-mat-icon icon="schedule" size="m" class="text-warning"></wcs-mat-icon>
|
|
<div>
|
|
<h3 className="m-0 font-weight-bold">Circulations Liées (Prochaines 2h)</h3>
|
|
<small className="text-muted">Trains partageant le même matériel pouvant subir un effet domino</small>
|
|
</div>
|
|
</div>
|
|
|
|
{(() => {
|
|
const impacted = getImpactedHoraires(selectedHoraires[0]);
|
|
if (impacted.length === 0) {
|
|
return <div className="text-muted font-italic p-2">Aucun autre train impacté dans la tranche horaire.</div>;
|
|
}
|
|
return (
|
|
<div className="d-flex flex-column gap-2 mt-3">
|
|
{impacted.map(h => (
|
|
<div key={h.id} className="p-3 bg-light border rounded d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong>Train {h.numero_train}</strong> ({h.ligne_nom}) · Départ : <strong>{formatHeure(h.heure_depart)}</strong>
|
|
<div className="text-muted small">{h.gare_depart_nom} → {h.gare_arrivee_nom}</div>
|
|
</div>
|
|
<select
|
|
className="form-control"
|
|
style={{ maxWidth: '280px' }}
|
|
value=""
|
|
onChange={(e) => handleAssignNewMaterial(h, e.target.value, selectedHoraires[0].id)}
|
|
>
|
|
<option value="" disabled>Changer matériel pour ce train...</option>
|
|
{materiels
|
|
.filter(m => !getMaterielIds(h).includes(m.id))
|
|
.map(m => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.nom} - {m.nom_technique}
|
|
</option>
|
|
))
|
|
}
|
|
</select>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})()}
|
|
</wcs-card-body>
|
|
</wcs-card>
|
|
)}
|
|
|
|
{/* Sillon de substitution */}
|
|
{selectedHoraires.length === 1 && perturbationsArretsMap[selectedHoraires[0].id]?.some(a => (a.types_perturbation || []).some(t => ['arret_supprime', 'modification_parcours'].includes(t))) && (
|
|
<wcs-card className="mb-4">
|
|
<wcs-card-body>
|
|
<div className="d-flex align-items-center gap-2 mb-3">
|
|
<wcs-mat-icon icon="swap_calls" size="m" class="text-primary"></wcs-mat-icon>
|
|
<div>
|
|
<h3 className="m-0 font-weight-bold">Train de Substitution</h3>
|
|
<small className="text-muted">Circulation de remplacement pour les voyageurs</small>
|
|
</div>
|
|
</div>
|
|
<wcs-form-field>
|
|
<label htmlFor="sillon-sub">Train de substitution (optionnel)</label>
|
|
<select
|
|
id="sillon-sub"
|
|
value={sillonSubstitutionId}
|
|
onChange={e => setSillonSubstitutionId(e.target.value)}
|
|
className="form-control"
|
|
>
|
|
<option value="">Aucun</option>
|
|
{horaires.filter(h => h.id !== selectedHoraires[0].id).slice(0, 50).map(h => (
|
|
<option key={h.id} value={h.id}>{h.numero_train} - {h.gare_depart_nom} vers {h.gare_arrivee_nom}</option>
|
|
))}
|
|
</select>
|
|
</wcs-form-field>
|
|
</wcs-card-body>
|
|
</wcs-card>
|
|
)}
|
|
|
|
{/* Jours de perturbation */}
|
|
<wcs-card className="mb-4">
|
|
<wcs-card-body>
|
|
<div className="d-flex align-items-center gap-2 mb-3">
|
|
<wcs-mat-icon icon="date_range" size="m" class="text-primary"></wcs-mat-icon>
|
|
<div>
|
|
<h3 className="m-0 font-weight-bold">Période d'application</h3>
|
|
<small className="text-muted">Définissez les dates ou plages de validité de la perturbation</small>
|
|
</div>
|
|
</div>
|
|
<JoursPerturbationEditor value={joursPerturbation} onChange={setJoursPerturbation} />
|
|
</wcs-card-body>
|
|
</wcs-card>
|
|
|
|
{/* Informations complémentaires & Statut actif */}
|
|
<wcs-card className="mb-4">
|
|
<wcs-card-body>
|
|
<div className="d-flex align-items-center gap-2 mb-3">
|
|
<wcs-mat-icon icon="notes" size="m" class="text-secondary"></wcs-mat-icon>
|
|
<div>
|
|
<h3 className="m-0 font-weight-bold">Informations d'exploitation</h3>
|
|
<small className="text-muted">Notes internes et visibilité</small>
|
|
</div>
|
|
</div>
|
|
|
|
<wcs-form-field class="mb-3">
|
|
<label htmlFor="notes-internes">Notes internes régulation</label>
|
|
<textarea
|
|
id="notes-internes"
|
|
placeholder="Consignes internes d'exploitation..."
|
|
value={notes}
|
|
onChange={e => setNotes(e.target.value)}
|
|
rows="3"
|
|
className="form-control"
|
|
/>
|
|
</wcs-form-field>
|
|
|
|
<div className="d-flex align-items-center justify-content-between p-3 rounded bg-light border">
|
|
<div>
|
|
<strong className="d-block">Perturbation active</strong>
|
|
<small className="text-muted">Diffuser immédiatement sur les écrans de quai et les flux voyageurs</small>
|
|
</div>
|
|
<label className="switch-container">
|
|
<input
|
|
type="checkbox"
|
|
checked={actif}
|
|
onChange={e => setActif(e.target.checked)}
|
|
/>
|
|
<span className="switch-slider"></span>
|
|
</label>
|
|
</div>
|
|
</wcs-card-body>
|
|
</wcs-card>
|
|
|
|
{/* Actions */}
|
|
<div className="step-actions d-flex justify-content-between align-items-center mt-4">
|
|
<wcs-button className="wcs-secondary" mode="stroked" onClick={() => setCurrentStep('horaire')}>
|
|
<wcs-mat-icon icon="arrow_back" size="s" class="mr-1" />
|
|
<span>Précédent</span>
|
|
</wcs-button>
|
|
|
|
<div className="d-flex gap-2">
|
|
<wcs-button className="wcs-secondary" mode="stroked" onClick={handleCancel}>
|
|
<span>Annuler</span>
|
|
</wcs-button>
|
|
<wcs-button
|
|
className="wcs-primary"
|
|
onClick={handleCreate}
|
|
disabled={saving || !canCreatePerturbations}
|
|
loading={saving}
|
|
>
|
|
<wcs-mat-icon icon="check" size="s" class="mr-1" />
|
|
<span>Créer la perturbation</span>
|
|
</wcs-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ── Rendu principal ───────────────────────────────────────────────────────
|
|
return (
|
|
<div className="sncf-groupe perturbations-page-root">
|
|
<div className="panel-layout">
|
|
<div className="panel-nav"><NavPanel /></div>
|
|
<div className="panel-content perturbations-modern-container">
|
|
{/* Header Banner SNCF */}
|
|
<header className="page-header-modern">
|
|
<div className="header-info">
|
|
<div className="perturbations-hero-tags mb-2 d-flex align-items-center gap-2">
|
|
<wcs-badge shape="pill" className="wcs-danger">
|
|
<wcs-mat-icon icon="add_alert" size="s" class="mr-1" />
|
|
Déclaration d'incident
|
|
</wcs-badge>
|
|
{scopedRegionId && (
|
|
<wcs-badge shape="pill" className="wcs-secondary">
|
|
Région : {scopedRegionId}
|
|
</wcs-badge>
|
|
)}
|
|
</div>
|
|
<h1>Déclarer une perturbation sur les circulations</h1>
|
|
<p>Signalez un retard, une suppression d'arrêt ou une modification de parcours en temps réel.</p>
|
|
</div>
|
|
|
|
<div className="header-actions">
|
|
<wcs-button className="wcs-secondary" mode="stroked" onClick={handleCancel}>
|
|
<wcs-mat-icon icon="close" size="s" class="mr-1" />
|
|
<span>Annuler</span>
|
|
</wcs-button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Stepper Horizontal SNCF */}
|
|
<div className="stepper-horizontal mb-4">
|
|
<div
|
|
className={`step-item ${currentStep === 'horaire' ? 'active' : 'completed'}`}
|
|
onClick={() => setCurrentStep('horaire')}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<div className="step-icon">
|
|
{currentStep === 'perturbation' ? <wcs-mat-icon icon="check" size="s" /> : '1'}
|
|
</div>
|
|
<span>1. Sélection des trains</span>
|
|
</div>
|
|
<div className="step-divider"></div>
|
|
<div
|
|
className={`step-item ${currentStep === 'perturbation' ? 'active' : ''}`}
|
|
onClick={() => selectedHoraires.length > 0 && setCurrentStep('perturbation')}
|
|
style={{ cursor: selectedHoraires.length > 0 ? 'pointer' : 'not-allowed' }}
|
|
>
|
|
<div className="step-icon">2</div>
|
|
<span>2. Configuration de l'incident & Parcours</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="creation-layout-modern">
|
|
<div className="creation-main-content">
|
|
{currentStep === 'horaire' && renderStepHoraire()}
|
|
{currentStep === 'perturbation' && renderStepPerturbation()}
|
|
</div>
|
|
|
|
<div className="sticky-sidebar">
|
|
{selectedHoraires.length === 1 && (
|
|
<ParcoursPreview arrets={perturbationsArretsMap[selectedHoraires[0].id]} />
|
|
)}
|
|
{selectedHoraires.length > 1 && (
|
|
<wcs-card className="summary-card">
|
|
<wcs-card-body>
|
|
<h3 className="m-0 mb-2 font-weight-bold">Sélection multiple ({selectedHoraires.length} trains)</h3>
|
|
<p className="text-muted small m-0">
|
|
Configurez le motif commun et ajustez les arrêts de chaque train via les accordéons.
|
|
</p>
|
|
</wcs-card-body>
|
|
</wcs-card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* WCS Footer Corporate SNCF */}
|
|
<wcs-footer className="sncf-page-footer mt-5">
|
|
<div className="d-flex align-items-center gap-3">
|
|
<div className="footer-sncf-badge">
|
|
<wcs-mat-icon icon="warning" style={{ fontSize: '20px', color: '#ffffff' }} />
|
|
</div>
|
|
<div>
|
|
<strong style={{ color: '#ffffff', fontSize: '14px', display: 'block' }}>
|
|
FERROVIA PANEL · CENTRE DE SUPERVISION DES CIRCULATIONS SNCF
|
|
</strong>
|
|
<span style={{ color: 'rgba(255, 255, 255, 0.75)', fontSize: '12px' }}>
|
|
Gestion Opérationnelle des Perturbations & Information Voyageurs
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div slot="end-left">
|
|
<span style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.65)' }}>
|
|
© {new Date().getFullYear()} SNCF · Système National de Régulation
|
|
</span>
|
|
</div>
|
|
|
|
<div slot="end-right">
|
|
<div className="d-flex align-items-center gap-2">
|
|
<wcs-badge className="wcs-success" shape="rounded">
|
|
<wcs-mat-icon icon="cloud_done" class="mr-1" />
|
|
Supabase Connecté
|
|
</wcs-badge>
|
|
<span style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.7)' }}>
|
|
v2.6.0-WCS
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</wcs-footer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|