MIGRATION VERS GITEA
This commit is contained in:
commit
5f9d991c15
333 files changed
+108514
No files matched your search
@@ -0,0 +1,796 @@
|
||||
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,
|
||||
getPrimaryType,
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
// Trier par numéro de train
|
||||
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 })); // Open by default
|
||||
}, []);
|
||||
|
||||
const handleSelectSingleHoraire = useCallback((h) => {
|
||||
setSelectedHoraires([h]);
|
||||
setSearchQuery(`${h.numero_train} - ${h.gare_depart_nom} → ${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 = a.retard_minutes || 0; }
|
||||
if (typeValue === 'arret_prolonge') { a.manual_arret_prolonge = true; a.arret_prolonge_minutes = a.arret_prolonge_minutes || 0; }
|
||||
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 === 'arret_prolonge') { a.manual_arret_prolonge = false; a.arret_prolonge_minutes = 0; }
|
||||
if (typeValue === 'arret_supprime') { a.is_nouveau_terminus = false; a.is_nouvelle_origine = false; }
|
||||
if (typeValue === 'modification_parcours') { a.is_nouveau_terminus = false; a.is_nouvelle_origine = false; }
|
||||
}
|
||||
a.types_perturbation = types;
|
||||
a.type_perturbation = getPrimaryType(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]];
|
||||
nextArr[index] = { ...nextArr[index], [field]: value };
|
||||
|
||||
if (field === 'retard_minutes') {
|
||||
nextArr[index].manual_retard = true;
|
||||
nextArr[index].retard_minutes = Number(value) || 0;
|
||||
const types = [...(nextArr[index].types_perturbation || [])];
|
||||
if (!types.includes('retard')) types.push('retard');
|
||||
nextArr[index].types_perturbation = types;
|
||||
nextArr[index].type_perturbation = getPrimaryType(types);
|
||||
}
|
||||
if (field === 'arret_prolonge_minutes') {
|
||||
const mins = Number(value) || 0;
|
||||
nextArr[index].arret_prolonge_minutes = mins;
|
||||
nextArr[index].manual_arret_prolonge = mins > 0;
|
||||
const types = [...(nextArr[index].types_perturbation || [])];
|
||||
if (mins > 0) { if (!types.includes('arret_prolonge')) types.push('arret_prolonge'); }
|
||||
else { const i2 = types.indexOf('arret_prolonge'); if (i2 !== -1) types.splice(i2, 1); }
|
||||
nextArr[index].types_perturbation = types;
|
||||
nextArr[index].type_perturbation = getPrimaryType(types);
|
||||
}
|
||||
nextMap[hId] = recomputePropagation(nextArr);
|
||||
return nextMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSetNouveauTerminus = useCallback((hId, index) => {
|
||||
setPerturbationsArretsMap(prev => {
|
||||
const nextMap = { ...prev };
|
||||
const nextArr = prev[hId].map((a, idx) => {
|
||||
if (idx === index) return { ...a, is_nouveau_terminus: true, is_nouvelle_origine: false };
|
||||
if (idx > index) return { ...a, types_perturbation: ['arret_supprime'], type_perturbation: 'arret_supprime', cause_arret: prev[hId][index]?.cause_arret || '', is_nouveau_terminus: false, 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 = prev[hId].map((a, idx) => {
|
||||
if (idx === index) return { ...a, is_nouvelle_origine: true, is_nouveau_terminus: false };
|
||||
if (idx < index) return { ...a, types_perturbation: ['arret_supprime'], type_perturbation: 'arret_supprime', cause_arret: prev[hId][index]?.cause_arret || '', is_nouveau_terminus: false, is_nouvelle_origine: 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 = [...prev[hId]];
|
||||
const newStop = {
|
||||
gare_nom: '',
|
||||
types_perturbation: ['modification_parcours'],
|
||||
type_perturbation: 'modification_parcours',
|
||||
is_added_manually: true,
|
||||
heure_arrivee: '',
|
||||
heure_depart: ''
|
||||
};
|
||||
nextArr.splice(index + 1, 0, newStop);
|
||||
const indexed = nextArr.map((a, idx) => ({ ...a, index: idx, isFirst: idx === 0, isLast: idx === nextArr.length - 1 }));
|
||||
nextMap[hId] = recomputePropagation(indexed);
|
||||
return nextMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeleteStop = useCallback((hId, index) => {
|
||||
setPerturbationsArretsMap(prev => {
|
||||
const nextMap = { ...prev };
|
||||
const nextArr = [...prev[hId]];
|
||||
if (nextArr[index].is_added_manually) {
|
||||
nextArr.splice(index, 1);
|
||||
} else {
|
||||
nextArr[index] = { ...nextArr[index], types_perturbation: ['arret_supprime'], type_perturbation: 'arret_supprime' };
|
||||
}
|
||||
const indexed = nextArr.map((a, idx) => ({ ...a, index: idx, isFirst: idx === 0, isLast: idx === nextArr.length - 1 }));
|
||||
nextMap[hId] = recomputePropagation(indexed);
|
||||
return nextMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Soumission ────────────────────────────────────────────────────────────
|
||||
const handleCreate = useCallback(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; }
|
||||
|
||||
// Check if at least one stop is perturbed for all selected horaires
|
||||
for (const h of selectedHoraires) {
|
||||
const arrets = perturbationsArretsMap[h.id];
|
||||
if (!arrets || !arrets.some(a => (a.types_perturbation || []).length > 0)) {
|
||||
alert(`Veuillez configurer au moins une perturbation pour le train ${h.numero_train}`); return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
let allSuccess = true;
|
||||
|
||||
try {
|
||||
for (const h of selectedHoraires) {
|
||||
const { payload, modificationsParcours } = buildPayload({
|
||||
selectedHoraire: h,
|
||||
perturbationsArrets: perturbationsArretsMap[h.id],
|
||||
cause, causeDetails,
|
||||
sillonSubstitutionId, joursPerturbation, actif, notes,
|
||||
});
|
||||
if (scopedRegionId) payload.region_id = scopedRegionId;
|
||||
const { error } = await createPerturbation(payload, modificationsParcours);
|
||||
if (error) {
|
||||
alert(`Erreur lors de la création pour le train ${h.numero_train}`);
|
||||
console.error(error);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allSuccess) {
|
||||
alert('Perturbations créées avec succès !');
|
||||
window.location.href = '/perturbations';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e); alert('Une erreur inattendue s\'est produite');
|
||||
} finally { setSaving(false); }
|
||||
}, [canCreatePerturbations, selectedHoraires, perturbationsArretsMap, cause, causeDetails, sillonSubstitutionId, joursPerturbation, actif, notes, scopedRegionId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (window.confirm('Êtes-vous sûr de vouloir annuler ?')) window.location.href = '/perturbations';
|
||||
}, []);
|
||||
|
||||
const getImpactedHoraires = useCallback((h) => {
|
||||
if (!h) return [];
|
||||
const selectedMrIds = getMaterielIds(h);
|
||||
if (selectedMrIds.length === 0) return [];
|
||||
|
||||
return horaires.filter(hor => {
|
||||
if (hor.id === h.id) return false;
|
||||
const hMrIds = getMaterielIds(hor);
|
||||
const shareMr = selectedMrIds.some(id => hMrIds.includes(id));
|
||||
if (!shareMr) return false;
|
||||
return isWithinTwoHours(h.heure_depart, hor.heure_depart);
|
||||
});
|
||||
}, [horaires]);
|
||||
|
||||
const handleAssignNewMaterial = useCallback(async (horaireToUpdate, newMaterielId, contextHoraireId) => {
|
||||
if (!horaireToUpdate || !newMaterielId) return;
|
||||
const selectedMr = materiels.find(m => String(m.id) === String(newMaterielId));
|
||||
if (!selectedMr) return;
|
||||
|
||||
setAssigningMateriel(true);
|
||||
try {
|
||||
const compositionPayload = [{
|
||||
materiel_id: selectedMr.id,
|
||||
materiel_nom: selectedMr.nom || '',
|
||||
materiel_nom_technique: selectedMr.nom_technique || '',
|
||||
materiel_image_url: selectedMr.image_url || null,
|
||||
position: 1
|
||||
}];
|
||||
|
||||
const { data, error } = await updateHoraireComposition(horaireToUpdate.id, compositionPayload);
|
||||
if (error) {
|
||||
alert("Erreur lors de l'attribution du matériel: " + (error.message || error));
|
||||
setAssigningMateriel(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedHoraires.some(h => h.id === horaireToUpdate.id)) {
|
||||
// Update selected horaires state
|
||||
setSelectedHoraires(prev => prev.map(h =>
|
||||
h.id === horaireToUpdate.id ? { ...h, composition_train: data?.composition_train || compositionPayload } : h
|
||||
));
|
||||
|
||||
setCause('modification_materiel');
|
||||
setPerturbationsArretsMap(prev => {
|
||||
const nextMap = { ...prev };
|
||||
const nextArr = [...nextMap[horaireToUpdate.id]];
|
||||
if (nextArr.length > 0) {
|
||||
const first = { ...nextArr[0] };
|
||||
first.manual_retard = true;
|
||||
first.retard_minutes = 25;
|
||||
const types = [...(first.types_perturbation || [])];
|
||||
if (!types.includes('retard')) types.push('retard');
|
||||
first.types_perturbation = types;
|
||||
first.type_perturbation = getPrimaryType(types);
|
||||
nextArr[0] = first;
|
||||
}
|
||||
nextMap[horaireToUpdate.id] = recomputePropagation(nextArr);
|
||||
return nextMap;
|
||||
});
|
||||
} else {
|
||||
const arrets = buildArretFromHoraire(horaireToUpdate);
|
||||
if (arrets.length > 0) {
|
||||
arrets[0].manual_retard = true;
|
||||
arrets[0].retard_minutes = 25;
|
||||
const types = [...(arrets[0].types_perturbation || [])];
|
||||
if (!types.includes('retard')) types.push('retard');
|
||||
arrets[0].types_perturbation = types;
|
||||
arrets[0].type_perturbation = getPrimaryType(types);
|
||||
}
|
||||
const propagated = recomputePropagation(arrets);
|
||||
const { payload, modificationsParcours } = buildPayload({
|
||||
selectedHoraire: horaireToUpdate,
|
||||
perturbationsArrets: propagated,
|
||||
cause: 'modification_materiel',
|
||||
causeDetails: 'Modification de matériel suite à indisponibilité du matériel initial',
|
||||
sillonSubstitutionId: '',
|
||||
joursPerturbation: joursPerturbation,
|
||||
actif: true,
|
||||
notes: 'Généré automatiquement suite à la réaffectation du matériel roulant.'
|
||||
});
|
||||
if (scopedRegionId) payload.region_id = scopedRegionId;
|
||||
const { error: pertErr } = await createPerturbation(payload, modificationsParcours);
|
||||
if (pertErr) {
|
||||
console.error("Erreur lors de la création automatique de la perturbation:", pertErr);
|
||||
} else {
|
||||
alert(`Matériel modifié. Une perturbation a été automatiquement créée pour le train ${horaireToUpdate.numero_train} avec 25 minutes de retard.`);
|
||||
}
|
||||
}
|
||||
|
||||
const regionId = getScopedRegionId(getCurrentUser());
|
||||
const { data: hData } = await getAllHoraires(regionId);
|
||||
if (hData) {
|
||||
setHoraires(hData);
|
||||
setFilteredHoraires(hData);
|
||||
// Refresh selected horaires
|
||||
setSelectedHoraires(prev => prev.map(sh => {
|
||||
const updated = hData.find(h => h.id === sh.id);
|
||||
return updated ? updated : sh;
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Une erreur est survenue lors de l'attribution.");
|
||||
} finally {
|
||||
setAssigningMateriel(false);
|
||||
}
|
||||
}, [selectedHoraires, materiels, scopedRegionId, joursPerturbation]);
|
||||
|
||||
|
||||
// ── Étape 1 ───────────────────────────────────────────────────────────────
|
||||
const renderStepHoraire = () => (
|
||||
<div className="step-container">
|
||||
<div className="step-header">
|
||||
<h2>Sélectionner un ou plusieurs horaires</h2>
|
||||
<p>Recherchez et sélectionnez les horaires qui seront perturbés</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group required">
|
||||
<label>Options de recherche</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: '20px', marginBottom: '15px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', color: '#555', marginBottom: '4px' }}>Date de circulation</label>
|
||||
<input type="date" className="form-input" value={searchDate} onChange={e => setSearchDate(e.target.value)} style={{ padding: '8px', border: '1px solid #ccc', borderRadius: '4px', width: '100%' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', color: '#555', marginBottom: '4px' }}>Recherche par</label>
|
||||
<select className="form-select" value={searchMode} onChange={e => { setSearchMode(e.target.value); setSelectedHoraires([]); setPerturbationsArretsMap({}); }} style={{ padding: '8px', border: '1px solid #ccc', borderRadius: '4px', width: '100%' }}>
|
||||
<option value="train">Numéro de train</option>
|
||||
<option value="ligne">Ligne</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingHoraires ? (
|
||||
<div className="loading-inline"><wcs-spinner mode="border" /> Chargement des horaires...</div>
|
||||
) : (
|
||||
<>
|
||||
<form onSubmit={e => { e.preventDefault(); handleSearch(); }} className="search-form">
|
||||
<input type="text" className="search-input" placeholder={searchMode === 'train' ? "Numéro, gare, ligne..." : "Nom de la ligne..."}
|
||||
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" /> Rechercher
|
||||
</wcs-button>
|
||||
{(searchQuery || selectedHoraires.length > 0) && (
|
||||
<wcs-button type="button" className="wcs-secondary" onClick={handleClearSearch}>
|
||||
<wcs-mat-icon icon="clear" />
|
||||
</wcs-button>
|
||||
)}
|
||||
</form>
|
||||
{showResults && searchQuery && (
|
||||
<div className="results-count">
|
||||
{filteredHoraires.length} horaire{filteredHoraires.length !== 1 ? 's' : ''} trouvé{filteredHoraires.length !== 1 ? 's' : ''} circulant le {new Date(searchDate).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loadingHoraires && showResults && (
|
||||
<div className="horaire-results">
|
||||
{filteredHoraires.slice(0, 50).map(h => {
|
||||
const isSelected = selectedHoraires.some(sh => sh.id === h.id);
|
||||
return (
|
||||
<div key={h.id} className={`horaire-result-item${isSelected ? ' selected' : ''}`}
|
||||
onClick={() => searchMode === 'ligne' ? handleToggleSelectHoraire(h, !isSelected) : handleSelectSingleHoraire(h)}>
|
||||
{searchMode === 'ligne' && (
|
||||
<div style={{ marginRight: '15px' }}>
|
||||
<input type="checkbox" checked={isSelected} readOnly style={{ transform: 'scale(1.2)', cursor: 'pointer' }} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="horaire-result-header">
|
||||
<strong>{h.numero_train}</strong>
|
||||
{h.type_train && <wcs-badge className="wcs-info">{h.type_train}</wcs-badge>}
|
||||
{h.ligne_nom && <wcs-badge className="wcs-secondary">{h.ligne_nom}</wcs-badge>}
|
||||
</div>
|
||||
<div className="horaire-result-route">
|
||||
{h.gare_depart_nom} <span className="time-chip">{formatHeure(h.heure_depart)}</span>
|
||||
<wcs-mat-icon icon="arrow_forward" />
|
||||
{h.gare_arrivee_nom} <span className="time-chip">{formatHeure(h.heure_arrivee)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filteredHoraires.length === 0 && <div className="no-results">Aucun horaire trouvé pour cette date.</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedHoraires.length > 0 && (
|
||||
<div className="horaire-display" style={{ marginTop: '20px' }}>
|
||||
<div className="horaire-display-header">
|
||||
<h4>{selectedHoraires.length} Horaire{selectedHoraires.length > 1 ? 's' : ''} sélectionné{selectedHoraires.length > 1 ? 's' : ''}</h4>
|
||||
<wcs-badge className="wcs-success"><wcs-mat-icon icon="check_circle" /></wcs-badge>
|
||||
</div>
|
||||
<div className="horaire-display-content" style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{selectedHoraires.map(h => (
|
||||
<div key={h.id} style={{ padding: '8px 0', borderBottom: '1px solid #eee' }}>
|
||||
<strong>Train {h.numero_train}</strong> ({h.ligne_nom || 'N/A'}) - {h.gare_depart_nom} → {h.gare_arrivee_nom}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="step-actions">
|
||||
<wcs-button className="wcs-secondary" onClick={handleCancel}>Annuler</wcs-button>
|
||||
<wcs-button className="wcs-primary" onClick={() => selectedHoraires.length > 0 && setCurrentStep('perturbation')} disabled={selectedHoraires.length === 0}>
|
||||
Suivant <wcs-mat-icon icon="arrow_forward" />
|
||||
</wcs-button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Étape 2 ───────────────────────────────────────────────────────────────
|
||||
const renderStepPerturbation = () => (
|
||||
<div className="step-container">
|
||||
<div className="step-header">
|
||||
<h2>Définir la perturbation</h2>
|
||||
<p>Configurez les perturbations pour les horaires sélectionnés</p>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Parcours {selectedHoraires.length > 1 ? 'des trains' : 'du train'}</h3>
|
||||
<p className="section-hint">Cliquez sur un train pour configurer les perturbations de ses arrêts</p>
|
||||
|
||||
{selectedHoraires.map(h => (
|
||||
<div key={h.id} style={{ marginBottom: '15px', border: '1px solid #ccc', borderRadius: '8px', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{ padding: '12px 16px', background: '#e9ecef', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
onClick={() => toggleTrainAccordion(h.id)}
|
||||
>
|
||||
<h4 style={{ margin: 0, fontSize: '1rem', color: '#333' }}>
|
||||
Train {h.numero_train} ({h.gare_depart_nom} → {h.gare_arrivee_nom})
|
||||
</h4>
|
||||
<wcs-mat-icon icon={openTrains[h.id] ? 'expand_less' : 'expand_more'} style={{ color: '#555' }} />
|
||||
</div>
|
||||
|
||||
{openTrains[h.id] && (
|
||||
<div className="parcours-accordion" style={{ padding: '15px' }}>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Cause globale de la perturbation</h3>
|
||||
<div className="form-group required">
|
||||
<label htmlFor="cause">Cause principale</label>
|
||||
<select id="cause" value={cause} onChange={e => setCause(e.target.value)} className="form-select">
|
||||
{CAUSES_PERTURBATION.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="causeDetails">Détails supplémentaires</label>
|
||||
<textarea id="causeDetails" placeholder="Précisions sur la cause..." value={causeDetails}
|
||||
onChange={e => setCauseDetails(e.target.value)} rows="3" className="form-textarea" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modification de Matériel */}
|
||||
{selectedHoraires.length === 1 && (
|
||||
<div className="form-section">
|
||||
<h3>Modification de Matériel</h3>
|
||||
{assigningMateriel ? (
|
||||
<div className="loading-inline"><wcs-spinner mode="border" /> Affectation en cours...</div>
|
||||
) : (
|
||||
<div style={{ background: '#f5f5f7', padding: '16px', borderRadius: '8px', border: '1px solid #e5e5ea' }}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<strong>Matériel actuel :</strong>{' '}
|
||||
{selectedHoraires[0].composition_train && selectedHoraires[0].composition_train.length > 0 ? (
|
||||
<span>
|
||||
{selectedHoraires[0].composition_train.map(c => `${c.materiel_nom} (${c.materiel_nom_technique})`).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#8e8e93', fontStyle: 'italic' }}>Aucun matériel attribué</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label htmlFor="change-mr">Attribuer un autre matériel</label>
|
||||
<select
|
||||
id="change-mr"
|
||||
className="form-select"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Horaires Impactés */}
|
||||
{selectedHoraires.length === 1 && (
|
||||
<div className="form-section">
|
||||
<h3>Horaires Impactés</h3>
|
||||
<p className="section-hint">
|
||||
Sillons utilisant le même matériel dans les 2 prochaines heures
|
||||
</p>
|
||||
{(() => {
|
||||
const impacted = getImpactedHoraires(selectedHoraires[0]);
|
||||
if (impacted.length === 0) {
|
||||
return <div style={{ color: '#8e8e93', fontStyle: 'italic', padding: '8px' }}>Aucun autre horaire impacté.</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{impacted.map(h => (
|
||||
<div key={h.id} style={{ background: '#fffbeb', border: '1px solid #fde68a', padding: '12px', borderRadius: '6px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<strong>Train {h.numero_train} ({h.ligne_nom})</strong>
|
||||
<span style={{ fontSize: '0.9rem', color: '#b45309', fontWeight: 'bold' }}>
|
||||
Départ : {formatHeure(h.heure_depart)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{h.gare_depart_nom} → {h.gare_arrivee_nom}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', color: '#4b5563' }}>
|
||||
Matériel actuel : {h.composition_train?.map(c => `${c.materiel_nom}`).join(', ') || 'Aucun'}
|
||||
</div>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ padding: '4px 8px', fontSize: '0.9rem' }}
|
||||
value=""
|
||||
onChange={(e) => handleAssignNewMaterial(h, e.target.value, selectedHoraires[0].id)}
|
||||
>
|
||||
<option value="" disabled>Attribuer un autre 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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedHoraires.length === 1 && perturbationsArretsMap[selectedHoraires[0].id]?.some(a => (a.types_perturbation || []).some(t => ['arret_supprime', 'modification_parcours'].includes(t))) && (
|
||||
<div className="form-section">
|
||||
<h3>Sillon de substitution</h3>
|
||||
<div className="form-group">
|
||||
<label htmlFor="sillon">Train de substitution (optionnel)</label>
|
||||
<select id="sillon" value={sillonSubstitutionId} onChange={e => setSillonSubstitutionId(e.target.value)} className="form-select">
|
||||
<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} → {h.gare_arrivee_nom}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Jours de perturbation</h3>
|
||||
<p className="section-hint">Ajoutez un ou plusieurs jours ou périodes d'application de la perturbation.</p>
|
||||
<JoursPerturbationEditor value={joursPerturbation} onChange={setJoursPerturbation} />
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Autres informations</h3>
|
||||
<div className="form-group">
|
||||
<label htmlFor="notes">Notes internes</label>
|
||||
<textarea id="notes" placeholder="Informations complémentaires..." value={notes}
|
||||
onChange={e => setNotes(e.target.value)} rows="3" className="form-textarea" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={actif} onChange={e => setActif(e.target.checked)} />
|
||||
<span>Perturbation active</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="step-actions">
|
||||
<wcs-button className="wcs-secondary" onClick={() => setCurrentStep('horaire')}>
|
||||
<wcs-mat-icon icon="arrow_back" /> Précédent
|
||||
</wcs-button>
|
||||
<div className="step-actions-right">
|
||||
<wcs-button className="wcs-secondary" onClick={handleCancel}>Annuler</wcs-button>
|
||||
<wcs-button className="wcs-primary" onClick={handleCreate} disabled={saving || !canCreatePerturbations}>
|
||||
{saving ? <span><wcs-spinner mode="border" /> Création...</span>
|
||||
: <span><wcs-mat-icon icon="check" /> Créer la perturbation</span>}
|
||||
</wcs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Rendu principal ───────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="sncf-groupe">
|
||||
<div className="panel-layout">
|
||||
<div className="panel-nav"><NavPanel /></div>
|
||||
<div className="panel-content">
|
||||
<header className="page-header">
|
||||
<h1>Créer une perturbation</h1>
|
||||
<p className="page-description">
|
||||
Étape {currentStep === 'horaire' ? '1' : '2'} / 2 —{' '}
|
||||
{currentStep === 'horaire' ? 'Sélection des horaires' : 'Configuration'}
|
||||
</p>
|
||||
</header>
|
||||
<div className="creation-container">
|
||||
<div className="creation-main">
|
||||
{currentStep === 'horaire' && renderStepHoraire()}
|
||||
{currentStep === 'perturbation' && renderStepPerturbation()}
|
||||
</div>
|
||||
<div className="creation-preview">
|
||||
{selectedHoraires.length === 1 && (
|
||||
<ParcoursPreview arrets={perturbationsArretsMap[selectedHoraires[0].id]} />
|
||||
)}
|
||||
{selectedHoraires.length > 1 && (
|
||||
<div style={{ padding: '15px', color: '#666', fontStyle: 'italic', background: '#f9f9f9', borderRadius: '8px', border: '1px solid #ddd' }}>
|
||||
Aperçu du parcours non disponible lors de la sélection multiple. Veuillez vous référer aux accordéons détaillés.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user