UPDATE PROCHAINS D2PARTS
This commit is contained in:
1 parent
d54ba7163a
commit
710b5a3a6d
12 files changed
+1780
-503
No files matched your search
@@ -82,14 +82,14 @@ export default async function RegionPage({
|
||||
console.error('Error fetching info_trafic:', e);
|
||||
}
|
||||
|
||||
// 3. Fetch actualites strictly filtered by region_id
|
||||
// 3. Fetch actualites (region-specific or global with region_id null)
|
||||
let actualitesList: ActualiteItem[] = [];
|
||||
try {
|
||||
const { data: actusData } = await supabase
|
||||
.from('actualites')
|
||||
.select('*')
|
||||
.eq('actif', true)
|
||||
.in('region_id', regionCandidates)
|
||||
.or(`region_id.is.null,region_id.in.(${regionCandidates.join(',')})`)
|
||||
.is('deleted_at', null)
|
||||
.order('date_publication', { ascending: false })
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -100,14 +100,14 @@ export default async function RegionPage({
|
||||
console.error('Error fetching actualites:', e);
|
||||
}
|
||||
|
||||
// 4. Fetch evenements strictly filtered by region_id
|
||||
// 4. Fetch evenements (region-specific or global with region_id null)
|
||||
let evenementsList: EvenementItem[] = [];
|
||||
try {
|
||||
const { data: eventsData } = await supabase
|
||||
.from('evenements')
|
||||
.select('*')
|
||||
.eq('actif', true)
|
||||
.in('region_id', regionCandidates)
|
||||
.or(`region_id.is.null,region_id.in.(${regionCandidates.join(',')})`)
|
||||
.is('deleted_at', null)
|
||||
.order('date_debut', { ascending: false });
|
||||
if (eventsData) {
|
||||
|
||||
+56
-1
@@ -1,5 +1,9 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
|
||||
export const metadata = {
|
||||
title: 'Portail des Régions | TER Ferrovia',
|
||||
@@ -20,7 +24,22 @@ const regions = [
|
||||
{ slug: 'auvergne-rhone-alpes', name: 'Auvergne-Rhône-Alpes', logo: 'https://www.ter.sncf.com/assets/medias/aura/headerLogo.svg', color: '#004f9f' },
|
||||
];
|
||||
|
||||
export default function PortalPage() {
|
||||
export default async function PortalPage() {
|
||||
let actualites: any[] = [];
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('actualites')
|
||||
.select('*')
|
||||
.eq('actif', true)
|
||||
.is('deleted_at', null)
|
||||
.order('date_publication', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(6);
|
||||
if (data) actualites = data;
|
||||
} catch (err) {
|
||||
console.error('Error fetching actualites on portal page:', err);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f5f6f8] flex flex-col items-center justify-center font-sans">
|
||||
<div className="w-full bg-[#111] py-8 text-center border-b-[4px] border-[#961533]">
|
||||
@@ -62,6 +81,42 @@ export default function PortalPage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Section Actualités du réseau */}
|
||||
{actualites.length > 0 && (
|
||||
<div className="mt-16">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-[24px] font-bold text-gray-800">Actualités du réseau</h2>
|
||||
<p className="text-gray-500 mt-1 text-[14px]">Informations et actualités TER Ferrovia</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{actualites.map((actu) => (
|
||||
<div key={actu.id} className="bg-white rounded-xl shadow-sm border border-gray-200/80 p-5 flex flex-col justify-between hover:shadow-md transition-shadow">
|
||||
<div>
|
||||
{actu.image_couverture || actu.image_url ? (
|
||||
<div className="w-full h-44 rounded-lg overflow-hidden mb-3.5 bg-gray-100">
|
||||
<img src={actu.image_couverture || actu.image_url} alt={actu.titre} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-32 rounded-lg bg-gradient-to-r from-blue-700 to-indigo-800 p-4 text-white flex flex-col justify-between mb-3.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider bg-white/20 px-2 py-0.5 rounded-full w-fit">
|
||||
{actu.categorie || 'Actualité'}
|
||||
</span>
|
||||
<h4 className="font-bold text-[16px] line-clamp-2 leading-tight">{actu.titre}</h4>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="font-bold text-gray-900 text-[16px] mb-2 leading-snug">{actu.titre}</h3>
|
||||
<p className="text-gray-600 text-[13px] line-clamp-3 leading-relaxed mb-4">{actu.resume || actu.contenu}</p>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 border-t border-gray-100 pt-3">
|
||||
{actu.date_publication ? new Date(actu.date_publication).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<div className="inline-block bg-white px-6 py-4 rounded-lg shadow-sm border border-gray-200">
|
||||
|
||||
@@ -7,6 +7,9 @@ import { supabase } from '@/lib/supabase';
|
||||
import { getRegionTheme } from '@/lib/regionTheme';
|
||||
import SiteHeader from '@/components/SiteHeader';
|
||||
import SiteFooter from '@/components/SiteFooter';
|
||||
import { isPerturbationActiveOnDate, formatPerturbationCause, getPerturbationPriority } from '@/lib/perturbationUtils';
|
||||
import { resolveEffectiveMateriel } from '@/lib/materielUtils';
|
||||
import { resolvePerturbedRoute } from '@/lib/parcoursUtils';
|
||||
|
||||
interface OffersPageClientProps {
|
||||
regionSlug?: string;
|
||||
@@ -211,34 +214,28 @@ export default function OffersPageClient({
|
||||
};
|
||||
|
||||
// Helper to extract exact segment stops & times for user departure -> arrival segment
|
||||
const resolveTrainSegment = (item: any, depQuery: string, arrQuery: string, gMap: Record<string, string>) => {
|
||||
let stops: TrainStop[] = [];
|
||||
|
||||
if (item.gares_desservies && Array.isArray(item.gares_desservies) && item.gares_desservies.length > 0) {
|
||||
stops = item.gares_desservies.map((g: any) => ({
|
||||
const resolveTrainSegment = (resolvedStops: any[], depQuery: string, arrQuery: string, defaultFinalDest: string) => {
|
||||
// Only consider served, unsuppressed stops
|
||||
const activeStops: TrainStop[] = resolvedStops
|
||||
.filter((s: any) => !s.is_supprime)
|
||||
.map((g: any) => ({
|
||||
time: (g.heure_depart || g.heure_arrivee || '00:00').slice(0, 5),
|
||||
stationName: g.gare_nom || (g.gare_id ? gMap[g.gare_id] : ''),
|
||||
stationName: g.gare_nom || '',
|
||||
}));
|
||||
} else {
|
||||
stops = [
|
||||
{ time: (item.heure_depart || '08:00').slice(0, 5), stationName: item.gare_depart_nom || (item.gare_depart_id ? gMap[item.gare_depart_id] : '') || depQuery },
|
||||
{ time: (item.heure_arrivee || '08:45').slice(0, 5), stationName: item.gare_arrivee_nom || (item.gare_arrivee_id ? gMap[item.gare_arrivee_id] : '') || arrQuery },
|
||||
];
|
||||
}
|
||||
|
||||
const depIdx = stops.findIndex(s => matchStation(depQuery, s.stationName));
|
||||
const arrIdx = stops.findIndex(s => matchStation(arrQuery, s.stationName));
|
||||
const depIdx = activeStops.findIndex(s => matchStation(depQuery, s.stationName));
|
||||
const arrIdx = activeStops.findIndex(s => matchStation(arrQuery, s.stationName));
|
||||
|
||||
// STRICT DIRECTION FILTERING: Train MUST travel in direction depIdx < arrIdx!
|
||||
if (depIdx === -1 || arrIdx === -1 || depIdx >= arrIdx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segStops = stops.slice(depIdx, arrIdx + 1);
|
||||
const segDepTime = stops[depIdx].time;
|
||||
const segArrTime = stops[arrIdx].time;
|
||||
const segDepName = stops[depIdx].stationName;
|
||||
const segArrName = stops[arrIdx].stationName;
|
||||
const segStops = activeStops.slice(depIdx, arrIdx + 1);
|
||||
const segDepTime = activeStops[depIdx].time;
|
||||
const segArrTime = activeStops[arrIdx].time;
|
||||
const segDepName = activeStops[depIdx].stationName;
|
||||
const segArrName = activeStops[arrIdx].stationName;
|
||||
|
||||
// Calculate segment duration
|
||||
const [h1, m1] = segDepTime.split(':').map(Number);
|
||||
@@ -249,7 +246,7 @@ export default function OffersPageClient({
|
||||
const durMins = totalMins % 60;
|
||||
const durStr = durHours > 0 ? `${durHours}h${durMins < 10 ? '0' : ''}${durMins}` : `${durMins} min`;
|
||||
|
||||
const finalDest = stops[stops.length - 1]?.stationName || item.gare_arrivee_nom || arrQuery;
|
||||
const finalDest = activeStops[activeStops.length - 1]?.stationName || defaultFinalDest || arrQuery;
|
||||
|
||||
return {
|
||||
depTime: segDepTime,
|
||||
@@ -356,6 +353,11 @@ export default function OffersPageClient({
|
||||
.eq('actif', true)
|
||||
.is('deleted_at', null);
|
||||
|
||||
// 5. Query materiel-roulant from Supabase
|
||||
const { data: allMatsData } = await supabase
|
||||
.from('materiel-roulant')
|
||||
.select('*');
|
||||
|
||||
// Determine day of week column from outwardDate
|
||||
const dayLower = (outwardDate || '').toLowerCase();
|
||||
let targetCol = 'circule_lundi';
|
||||
@@ -397,7 +399,21 @@ export default function OffersPageClient({
|
||||
|
||||
if (!filterDay(item)) return;
|
||||
|
||||
const seg = resolveTrainSegment(item, departure, arrival, gMap);
|
||||
// Match active perturbation for this train strictly on target date
|
||||
const matchingPert = pertData?.find((p: any) => {
|
||||
const matchTrain =
|
||||
p.horaire_id === item.id ||
|
||||
String(p.horaire_id) === String(item.id) ||
|
||||
String(p.horaire_id) === String(item.numero_train) ||
|
||||
(p.numero_train && String(p.numero_train) === String(item.numero_train));
|
||||
if (!matchTrain) return false;
|
||||
return isPerturbationActiveOnDate(p, targetIsoDate);
|
||||
});
|
||||
|
||||
// Resolve real-world route considering active perturbations
|
||||
const resolvedParcours = resolvePerturbedRoute(item, matchingPert, gMap);
|
||||
|
||||
const seg = resolveTrainSegment(resolvedParcours.stops, departure, arrival, resolvedParcours.destination);
|
||||
if (!seg) return;
|
||||
|
||||
// Check if segment departure time is >= user's chosen time
|
||||
@@ -408,33 +424,53 @@ export default function OffersPageClient({
|
||||
if (seenKeys.has(uniqueKey)) return;
|
||||
seenKeys.add(uniqueKey);
|
||||
|
||||
// Match active perturbation for this train
|
||||
const matchingPert = pertData?.find((p: any) => {
|
||||
const matchTrain = p.horaire_id === item.id || p.horaire_id === item.numero_train;
|
||||
if (!matchTrain) return false;
|
||||
let pertType = matchingPert?.type_perturbation || (resolvedParcours.hasParcoursModification ? 'parcours_modifie' : 'Perturbation');
|
||||
let pertDelay = matchingPert?.duree_retard_minutes || 0;
|
||||
let pertCause = matchingPert?.cause || '';
|
||||
let pertDetails = matchingPert?.cause_details || matchingPert?.notes || '';
|
||||
|
||||
const pStart = (p.date_debut || '').slice(0, 10);
|
||||
const pEnd = (p.date_fin || '').slice(0, 10);
|
||||
if (matchingPert) {
|
||||
const arretsList = Array.isArray(matchingPert.perturbations_arrets)
|
||||
? matchingPert.perturbations_arrets
|
||||
: Array.isArray(matchingPert.perturbations_arrets?.arrets)
|
||||
? matchingPert.perturbations_arrets.arrets
|
||||
: [];
|
||||
|
||||
if (pStart && pEnd && pStart <= targetIsoDate && targetIsoDate <= pEnd) {
|
||||
return true;
|
||||
}
|
||||
if (p.jours_perturbation && Array.isArray(p.jours_perturbation)) {
|
||||
const jpMatch = p.jours_perturbation.some((jp: any) => {
|
||||
const d = (jp.date || jp.dateDebut || '').slice(0, 10);
|
||||
return d === targetIsoDate;
|
||||
if (arretsList.length > 0) {
|
||||
const stopPert = arretsList.find((pa: any) => {
|
||||
const paNom = (pa.gare_nom || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||
const depNorm = (seg.depName || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||
return paNom.includes(depNorm) || depNorm.includes(paNom);
|
||||
});
|
||||
if (jpMatch) return true;
|
||||
if (stopPert) {
|
||||
if (stopPert.type_perturbation) pertType = stopPert.type_perturbation;
|
||||
if (stopPert.cause_arret) pertDetails = stopPert.cause_arret;
|
||||
const stopDelay = stopPert.retard_cumule ?? stopPert.retard_entrant ?? stopPert.retard_minutes;
|
||||
if (typeof stopDelay === 'number' && stopDelay > 0) {
|
||||
pertDelay = stopDelay;
|
||||
}
|
||||
}
|
||||
}
|
||||
return !pStart && !pEnd && (!p.jours_perturbation || p.jours_perturbation.length === 0);
|
||||
}
|
||||
|
||||
const priority = getPerturbationPriority({
|
||||
isSupprime: matchingPert?.type_perturbation === 'suppression',
|
||||
hasParcoursModification: resolvedParcours.hasParcoursModification,
|
||||
perturbationType: pertType,
|
||||
delayMinutes: pertDelay,
|
||||
});
|
||||
|
||||
let pertLabel = 'Information';
|
||||
if (priority === 'suppression') pertLabel = 'Train supprimé';
|
||||
else if (priority === 'parcours_modifie') pertLabel = 'Parcours modifié';
|
||||
else if (priority === 'retard') pertLabel = 'Retard';
|
||||
|
||||
const pertInfo: PerturbationInfo | null = matchingPert ? {
|
||||
id: matchingPert.id?.toString(),
|
||||
type: matchingPert.type_perturbation || 'Perturbation',
|
||||
cause: matchingPert.cause || 'Incident de circulation',
|
||||
causeDetails: matchingPert.cause_details || matchingPert.notes || '',
|
||||
delayMinutes: matchingPert.duree_retard_minutes || 0,
|
||||
type: pertLabel,
|
||||
cause: formatPerturbationCause(pertCause) || 'Incident de circulation',
|
||||
causeDetails: pertDetails,
|
||||
delayMinutes: pertDelay,
|
||||
notes: matchingPert.notes || '',
|
||||
} : null;
|
||||
|
||||
@@ -442,15 +478,19 @@ export default function OffersPageClient({
|
||||
const isShortRoute = seg.arrName.toLowerCase().includes('pagny') || seg.arrName.toLowerCase().includes('brazey') || seg.depName.toLowerCase().includes('brazey');
|
||||
const priceVal = isShortRoute ? '6,50 €' : '13,00 €';
|
||||
|
||||
// Extract matériel roulant image from composition_train BDD
|
||||
const composition = Array.isArray(item.composition_train) ? item.composition_train : [];
|
||||
const firstMat = composition.length > 0 ? composition[0] : null;
|
||||
|
||||
// Extract matériel roulant image using resolveEffectiveMateriel
|
||||
const diffDays = Math.ceil((new Date(targetIsoDate).getTime() - new Date().setHours(0,0,0,0)) / (1000 * 60 * 60 * 24));
|
||||
const showMateriel = diffDays <= 1;
|
||||
|
||||
const matImageUrl = showMateriel ? (firstMat?.materiel_image_url || 'https://supabase-ferrovia.mrpatator.fr/storage/v1/object/public/materiel-roulant/images/1789909846752_regiolis_firsttosecond_bluegreenmobigo_4c__1_.png') : undefined;
|
||||
const matNameText = firstMat ? `Un train "${firstMat.materiel_nom || 'Régiolis'}" ${firstMat.materiel_nom_technique ? `(${firstMat.materiel_nom_technique})` : ''}` : (item.modele_train || 'Un train "AGC"');
|
||||
|
||||
const effectiveMat = resolveEffectiveMateriel(item.composition_train, allMatsData || [], {
|
||||
modeleTrain: item.modele_train || 'Régiolis',
|
||||
capaciteTexte: item.capacite_texte,
|
||||
imageUrl: 'https://supabase-ferrovia.mrpatator.fr/storage/v1/object/public/materiel-roulant/images/1789909846752_regiolis_firsttosecond_bluegreenmobigo_4c__1_.png'
|
||||
});
|
||||
|
||||
const matImageUrl = showMateriel ? effectiveMat.imageUrl : undefined;
|
||||
const matNameText = `Un train "${effectiveMat.nom}"${effectiveMat.nomTechnique ? ` (${effectiveMat.nomTechnique})` : ''}`;
|
||||
const capacityVal = effectiveMat.places ? `${effectiveMat.voitures} voitures - ${effectiveMat.places} places` : (item.capacite_texte || '4 voitures - 220 places');
|
||||
|
||||
mappedList.push({
|
||||
id: item.id?.toString() || idx.toString(),
|
||||
@@ -464,7 +504,7 @@ export default function OffersPageClient({
|
||||
finalDestination: seg.finalDestination,
|
||||
operator: item.operateur || 'SNCF Voyageurs - 1187',
|
||||
trainModel: matNameText,
|
||||
capacityText: item.capacite_texte || '4 voitures - 220 places',
|
||||
capacityText: capacityVal,
|
||||
onboardServices: '🚲',
|
||||
priceClass: idx === hData.length - 1 ? 'À partir de' : '2nd classe',
|
||||
price: priceVal,
|
||||
@@ -637,13 +677,27 @@ export default function OffersPageClient({
|
||||
>
|
||||
{/* PERTURBATION WARNING BANNER */}
|
||||
{offer.perturbation && (
|
||||
<div className="bg-amber-50 border-b border-amber-200 px-4 py-2 text-[12.5px] text-amber-900 flex items-center justify-between gap-2">
|
||||
<div className={`border-b px-4 py-2 text-[12.5px] flex items-center justify-between gap-2 ${
|
||||
offer.perturbation.type === 'Train supprimé'
|
||||
? 'bg-red-50 border-red-200 text-red-900'
|
||||
: offer.perturbation.type === 'Parcours modifié'
|
||||
? 'bg-blue-50 border-blue-200 text-blue-900'
|
||||
: 'bg-amber-50 border-amber-200 text-amber-900'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-icons text-amber-700 text-[18px]">warning</span>
|
||||
<span className="font-bold">Perturbation :</span>
|
||||
<span>{offer.perturbation.cause || offer.perturbation.type || 'Incident de circulation'}</span>
|
||||
<span className={`w-4 h-4 rounded-full text-white flex items-center justify-center text-[10px] font-bold shrink-0 ${
|
||||
offer.perturbation.type === 'Train supprimé'
|
||||
? 'bg-red-600'
|
||||
: offer.perturbation.type === 'Parcours modifié'
|
||||
? 'bg-[#0066a4]'
|
||||
: 'bg-[#e65100]'
|
||||
}`}>
|
||||
{offer.perturbation.type === 'Train supprimé' ? '✕' : offer.perturbation.type === 'Parcours modifié' ? '↔' : '!'}
|
||||
</span>
|
||||
<span className="font-bold">{offer.perturbation.type} :</span>
|
||||
<span>{offer.perturbation.cause || 'Incident de circulation'}</span>
|
||||
</div>
|
||||
{offer.perturbation.delayMinutes ? (
|
||||
{offer.perturbation.delayMinutes && offer.perturbation.type === 'Retard' ? (
|
||||
<span className="bg-amber-200 text-amber-950 px-2 py-0.5 rounded font-bold text-[11px] whitespace-nowrap">
|
||||
Retard : +{offer.perturbation.delayMinutes} min
|
||||
</span>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { supabase } from '@/lib/supabase';
|
||||
import SiteHeader from '@/components/SiteHeader';
|
||||
import SiteFooter from '@/components/SiteFooter';
|
||||
import { getRegionTheme } from '@/lib/regionTheme';
|
||||
import { isPerturbationActiveOnDate } from '@/lib/perturbationUtils';
|
||||
import { isPerturbationActiveOnDate, getPerturbationBannerStyle, formatPerturbationCause, getPerturbationPriority } from '@/lib/perturbationUtils';
|
||||
import { resolvePerturbedRoute } from '@/lib/parcoursUtils';
|
||||
|
||||
interface ProchainsDepartsClientProps {
|
||||
regionSlug?: string;
|
||||
@@ -31,6 +32,9 @@ export interface StopItem {
|
||||
perturbationType?: string;
|
||||
perturbationDetails?: string;
|
||||
isSupprime?: boolean;
|
||||
isNouveauTerminus?: boolean;
|
||||
isNouvelleOrigine?: boolean;
|
||||
isAddedManually?: boolean;
|
||||
retardMinutes?: number;
|
||||
}
|
||||
|
||||
@@ -40,6 +44,7 @@ export interface DepartureRow {
|
||||
revisedTime?: string;
|
||||
delayText?: string;
|
||||
isSupprime?: boolean;
|
||||
isTrainSupplementaire?: boolean;
|
||||
destination: string;
|
||||
modeIcon: 'bus' | 'train';
|
||||
modeLabel: string;
|
||||
@@ -62,6 +67,7 @@ export interface ArrivalRow {
|
||||
revisedTime?: string;
|
||||
delayText?: string;
|
||||
isSupprime?: boolean;
|
||||
isTrainSupplementaire?: boolean;
|
||||
provenance: string;
|
||||
modeIcon: 'bus' | 'train';
|
||||
modeLabel: string;
|
||||
@@ -910,18 +916,35 @@ export default function ProchainsDepartsClient({
|
||||
const arrList: ArrivalRow[] = [];
|
||||
const dateLabelStr = isNextDay ? formatDateLabel(targetDateIso) : undefined;
|
||||
|
||||
hData.forEach((h: any, idx: number) => {
|
||||
if (!checkCirculation(h, targetDateIso)) return;
|
||||
|
||||
let gares = [];
|
||||
if (typeof h.gares_desservies === 'string') {
|
||||
try { gares = JSON.parse(h.gares_desservies); } catch (e) {}
|
||||
} else if (Array.isArray(h.gares_desservies)) {
|
||||
gares = h.gares_desservies;
|
||||
// Collect active substitution horaire IDs for targetDateIso
|
||||
const subHoraireIds = new Set<string>();
|
||||
pertData?.forEach((p: any) => {
|
||||
if (p.sillon_substitution_id && isPerturbationActiveOnDate(p, targetDateIso)) {
|
||||
subHoraireIds.add(String(p.sillon_substitution_id));
|
||||
}
|
||||
});
|
||||
|
||||
hData.forEach((h: any, idx: number) => {
|
||||
const isSub = subHoraireIds.has(String(h.id)) || subHoraireIds.has(String(h.numero_train));
|
||||
const isCirculating = checkCirculation(h, targetDateIso);
|
||||
if (!isCirculating && !isSub) return;
|
||||
|
||||
// Perturbation match strictly on timetable target date
|
||||
const matchingPert = pertData?.find((p: any) => {
|
||||
return (
|
||||
p.horaire_id === h.id ||
|
||||
String(p.horaire_id) === String(h.id) ||
|
||||
String(p.horaire_id) === String(h.numero_train) ||
|
||||
(p.numero_train && String(p.numero_train) === String(h.numero_train))
|
||||
) && isPerturbationActiveOnDate(p, targetDateIso);
|
||||
});
|
||||
|
||||
// Resolve real-world route considering perturbations (new terminus, new origin, deleted or added stops)
|
||||
const resolvedParcours = resolvePerturbedRoute(h, matchingPert, gMap);
|
||||
const gares = resolvedParcours.stops;
|
||||
|
||||
const stopIdx = gares.findIndex((g: any) => {
|
||||
const gNom = (g.gare_nom || gMap[g.gare_id] || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||
const gNom = (g.gare_nom || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||
return gNom.includes(cleanName) || cleanName.includes(gNom);
|
||||
});
|
||||
|
||||
@@ -929,16 +952,11 @@ export default function ProchainsDepartsClient({
|
||||
const stop = gares[stopIdx];
|
||||
const depTime = (stop.heure_depart || stop.heure_arrivee || '').slice(0, 5) || '08:00';
|
||||
const arrTime = (stop.heure_arrivee || stop.heure_depart || '').slice(0, 5) || '08:00';
|
||||
const isTerminus = stopIdx === gares.length - 1 && !stop.heure_depart;
|
||||
const isOrigin = stopIdx === 0;
|
||||
const isTerminus = stop.is_nouveau_terminus || (stopIdx === gares.length - 1 && !stop.heure_depart) || stop.is_arrivee;
|
||||
const isOrigin = stop.is_nouvelle_origine || stopIdx === 0 || stop.is_depart;
|
||||
|
||||
const badge = (h.ligne_id && lMap[h.ligne_id]) || (h.badge_ligne ? String(h.badge_ligne).trim() : undefined) || deriveBadge(h);
|
||||
|
||||
// Perturbation match
|
||||
const matchingPert = pertData?.find((p: any) => {
|
||||
return (p.horaire_id === h.id || String(p.horaire_id) === String(h.numero_train)) && isPerturbationActiveOnDate(p, targetDateIso);
|
||||
});
|
||||
|
||||
// Travaux match on line
|
||||
const matchingTravaux = trData?.find((t: any) => {
|
||||
const tL = (t.ligne_nom || '').toLowerCase().trim();
|
||||
@@ -959,37 +977,90 @@ export default function ProchainsDepartsClient({
|
||||
return true;
|
||||
});
|
||||
|
||||
let pertType: any = matchingPert?.type_perturbation || (matchingTravaux ? 'information' : undefined);
|
||||
let pertDetails: string | undefined = matchingPert?.cause_details || matchingPert?.cause || (matchingTravaux ? (matchingTravaux.description || matchingTravaux.titre) : undefined);
|
||||
let isSupp = matchingPert?.type_perturbation === 'suppression';
|
||||
let pertCause = matchingPert?.cause || '';
|
||||
let pertDetails: string | undefined = matchingPert?.cause_details || matchingPert?.notes || (matchingTravaux ? (matchingTravaux.description || matchingTravaux.titre) : undefined);
|
||||
let isSupp = matchingPert?.type_perturbation === 'suppression' || Boolean(stop.is_supprime);
|
||||
let delayMins = matchingPert?.duree_retard_minutes;
|
||||
|
||||
// Stop-specific cause and delay if defined
|
||||
if (stop.cause_arret) pertCause = stop.cause_arret;
|
||||
if (stop.retard_minutes !== undefined && stop.retard_minutes > 0) {
|
||||
delayMins = stop.retard_minutes;
|
||||
}
|
||||
|
||||
const formattedCause = formatPerturbationCause(pertCause);
|
||||
|
||||
// Strictly resolve priority: Supprimé > Modification de parcours > Retardé > A l'heure
|
||||
const priority = getPerturbationPriority({
|
||||
isSupprime: isSupp,
|
||||
hasParcoursModification: resolvedParcours.hasParcoursModification,
|
||||
perturbationType: matchingPert?.type_perturbation,
|
||||
typesPerturbation: stop.types_perturbation,
|
||||
delayMinutes: delayMins,
|
||||
});
|
||||
|
||||
let pertType: any = undefined;
|
||||
let revisedTimeStr: string | undefined = undefined;
|
||||
let delayTextStr: string | undefined = undefined;
|
||||
|
||||
if (isSupp) {
|
||||
delayTextStr = 'Train supprimé';
|
||||
} else if (matchingPert?.duree_retard_minutes) {
|
||||
const delayMins = matchingPert.duree_retard_minutes;
|
||||
if (priority === 'suppression') {
|
||||
isSupp = true;
|
||||
pertType = 'suppression';
|
||||
delayTextStr = formattedCause ? `Train supprimé (${formattedCause})` : (stop.cause_arret || 'Train supprimé');
|
||||
} else if (priority === 'parcours_modifie') {
|
||||
isSupp = false;
|
||||
pertType = 'parcours_modifie';
|
||||
const extraRouteInfo = resolvedParcours.nouveauTerminusNom ? `Terminus ${resolvedParcours.nouveauTerminusNom}` : (resolvedParcours.nouvelleOrigineNom ? `Origine ${resolvedParcours.nouvelleOrigineNom}` : '');
|
||||
if (formattedCause && extraRouteInfo) {
|
||||
delayTextStr = `Modification de parcours : ${extraRouteInfo} (${formattedCause})`;
|
||||
} else if (extraRouteInfo) {
|
||||
delayTextStr = `Modification de parcours : ${extraRouteInfo}`;
|
||||
} else if (formattedCause) {
|
||||
delayTextStr = `Modification de parcours (${formattedCause})`;
|
||||
} else {
|
||||
delayTextStr = 'Modification de parcours';
|
||||
}
|
||||
} else if (priority === 'retard') {
|
||||
isSupp = false;
|
||||
pertType = 'retard';
|
||||
const [hH, hM] = depTime.split(':').map(Number);
|
||||
const revTotal = hH * 60 + hM + delayMins;
|
||||
const revTotal = (isNaN(hH) ? 0 : hH) * 60 + (isNaN(hM) ? 0 : hM) + (delayMins || 0);
|
||||
const revH = Math.floor(revTotal / 60) % 24;
|
||||
const revM = revTotal % 60;
|
||||
revisedTimeStr = `${String(revH).padStart(2, '0')}:${String(revM).padStart(2, '0')}`;
|
||||
delayTextStr = `Retard estimé de ${delayMins} min`;
|
||||
delayTextStr = formattedCause ? `Retard estimé de ${delayMins} min (${formattedCause})` : `Retard estimé de ${delayMins} min`;
|
||||
} else {
|
||||
// A l'heure
|
||||
isSupp = false;
|
||||
if (matchingTravaux) {
|
||||
pertType = 'information';
|
||||
delayTextStr = matchingTravaux.titre || matchingTravaux.description || 'Information trafic';
|
||||
} else {
|
||||
pertType = undefined;
|
||||
delayTextStr = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Map stops for timeline
|
||||
const stopsMapped: StopItem[] = gares.map((g: any, gIdx: number) => {
|
||||
const sDep = (g.heure_depart || g.heure_arrivee || '').slice(0, 5);
|
||||
const sArr = (g.heure_arrivee || g.heure_depart || '').slice(0, 5);
|
||||
// Map stops for timeline accordion
|
||||
const stopsMapped: StopItem[] = gares.map((g: any) => {
|
||||
const sDep = g.heure_depart || g.heure_arrivee || '';
|
||||
const sArr = g.heure_arrivee || g.heure_depart || '';
|
||||
const rawQ = g.quai || g.quai_reel || '';
|
||||
return {
|
||||
departTime: sDep,
|
||||
arriveeTime: sArr,
|
||||
gareNom: g.gare_nom || gMap[g.gare_id] || '',
|
||||
gareNom: g.gare_nom,
|
||||
voie: formatQuaiDisplay(rawQ, sDep || sArr, targetDateIso, h.type_train, currentStationType),
|
||||
rawQuai: rawQ,
|
||||
isDepart: gIdx === 0,
|
||||
isArrivee: gIdx === gares.length - 1,
|
||||
isDepart: g.is_depart,
|
||||
isArrivee: g.is_arrivee,
|
||||
isSupprime: g.is_supprime,
|
||||
isNouveauTerminus: g.is_nouveau_terminus,
|
||||
isNouvelleOrigine: g.is_nouvelle_origine,
|
||||
isAddedManually: g.is_added_manually,
|
||||
retardMinutes: g.retard_minutes,
|
||||
perturbationType: g.type_perturbation || pertType,
|
||||
perturbationDetails: g.cause_arret || pertDetails,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1003,7 +1074,8 @@ export default function ProchainsDepartsClient({
|
||||
revisedTime: revisedTimeStr,
|
||||
delayText: delayTextStr,
|
||||
isSupprime: isSupp,
|
||||
destination: h.gare_arrivee_nom || gares[gares.length - 1]?.gare_nom || 'Terminus',
|
||||
isTrainSupplementaire: isSub,
|
||||
destination: resolvedParcours.destination,
|
||||
modeIcon: (h.type_train || '').toLowerCase().includes('car') ? 'bus' : 'train',
|
||||
modeLabel: h.type_train || 'Train MOBIGO',
|
||||
trainNumber: h.numero_train || `89${idx + 301}`,
|
||||
@@ -1027,7 +1099,8 @@ export default function ProchainsDepartsClient({
|
||||
revisedTime: revisedTimeStr,
|
||||
delayText: delayTextStr,
|
||||
isSupprime: isSupp,
|
||||
provenance: h.gare_depart_nom || gares[0]?.gare_nom || 'Origine',
|
||||
isTrainSupplementaire: isSub,
|
||||
provenance: resolvedParcours.provenance,
|
||||
modeIcon: (h.type_train || '').toLowerCase().includes('car') ? 'bus' : 'train',
|
||||
modeLabel: h.type_train || 'Train MOBIGO',
|
||||
trainNumber: h.numero_train || `89${idx + 301}`,
|
||||
@@ -1519,6 +1592,11 @@ export default function ProchainsDepartsClient({
|
||||
{item.trainBadge}
|
||||
</span>
|
||||
)}
|
||||
{item.isTrainSupplementaire && (
|
||||
<span className="bg-[#6f42c1] text-white text-[9px] font-bold px-1 py-0.2 rounded-[2px] shrink-0 leading-none uppercase">
|
||||
Train Supplémentaire
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1536,6 +1614,11 @@ export default function ProchainsDepartsClient({
|
||||
{item.trainBadge}
|
||||
</span>
|
||||
)}
|
||||
{item.isTrainSupplementaire && (
|
||||
<span className="bg-[#6f42c1] text-white text-[10px] font-bold px-1.5 py-0.5 rounded-[3px] shrink-0 leading-none uppercase">
|
||||
Train Supplémentaire
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column 4: Voie (prominently visible on mobile & desktop) */}
|
||||
@@ -1564,23 +1647,44 @@ export default function ProchainsDepartsClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Information Banner on closed row */}
|
||||
{hasInfo && !isExpanded && (
|
||||
<div
|
||||
onClick={() => setExpandedId(isExpanded ? null : item.id)}
|
||||
className="relative bg-[#e8f4fc] px-4 py-1.5 sm:px-6 sm:py-1.5 text-[12px] text-[#0055a5] flex items-center gap-2 cursor-pointer border-t border-b border-[#d8ebf9]"
|
||||
>
|
||||
<div className="absolute -top-[6px] left-6 sm:left-10 w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[6px] border-b-[#e8f4fc]" />
|
||||
<span className="w-4 h-4 rounded-full bg-[#0055a5] text-white flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
i
|
||||
</span>
|
||||
<span className="font-normal text-[12px]">
|
||||
{item.isSupprime ? 'Train supprimé' : (item.delayText || 'Information')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Status Banner on closed row (retard, suppression, modification de parcours) */}
|
||||
{hasInfo && !isExpanded && (() => {
|
||||
const priority = getPerturbationPriority({
|
||||
isSupprime: item.isSupprime,
|
||||
hasParcoursModification: item.perturbationType === 'parcours_modifie' || item.perturbationType?.includes('parcours'),
|
||||
perturbationType: item.perturbationType,
|
||||
delayMinutes: item.revisedTime ? 1 : undefined,
|
||||
});
|
||||
if (priority === 'a_l_heure' && item.perturbationType !== 'information') return null;
|
||||
const bStyle = getPerturbationBannerStyle(priority === 'a_l_heure' ? 'information' : priority);
|
||||
return (
|
||||
<div
|
||||
onClick={() => setExpandedId(isExpanded ? null : item.id)}
|
||||
className="relative px-4 py-1.5 sm:px-6 sm:py-1.5 text-[12px] flex items-center gap-2 cursor-pointer border-t border-b"
|
||||
style={{
|
||||
backgroundColor: bStyle.bgColor,
|
||||
borderColor: `${bStyle.textColor}33`,
|
||||
color: bStyle.textColor,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-[6px] left-6 sm:left-10 w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[6px]"
|
||||
style={{ borderBottomColor: bStyle.bgColor }}
|
||||
/>
|
||||
<span
|
||||
className="w-4 h-4 rounded-full text-white flex items-center justify-center text-[10px] font-bold shrink-0"
|
||||
style={{ backgroundColor: bStyle.iconBgColor }}
|
||||
>
|
||||
{bStyle.iconText}
|
||||
</span>
|
||||
<span className="font-semibold text-[12px]">
|
||||
{item.delayText || bStyle.defaultCause || 'Information'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ─── EXPANDED TIMELINE ACCORDION (STRICTLY CONFORME IMG 2) ─── */}
|
||||
{/* ─── EXPANDED TIMELINE ACCORDION (STRICTLY CONFORME IMG 2 & IMG 1) ─── */}
|
||||
{isExpanded && (
|
||||
<div className="bg-[#edf4d2] p-4 sm:p-6 border-t border-b border-[#d8e2be] animate-fadeIn text-[#222]">
|
||||
|
||||
@@ -1588,7 +1692,7 @@ export default function ProchainsDepartsClient({
|
||||
<h3 className="text-[20px] sm:text-[22px] font-bold text-gray-900 mb-1">
|
||||
Liste des gares
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-[14px] text-gray-800 font-normal mb-1">
|
||||
<div className="flex items-center gap-2 text-[14px] text-gray-800 font-normal mb-1 flex-wrap">
|
||||
<span>
|
||||
desservies par le {item.modeIcon === 'bus' ? 'CAR' : 'TRAIN'} {(item.modeLabel || 'MOBIGO').toUpperCase().replace(/^(TRAIN|CAR)\s+/i, '')} {item.trainNumber || ''}
|
||||
</span>
|
||||
@@ -1600,34 +1704,74 @@ export default function ProchainsDepartsClient({
|
||||
{item.trainBadge}
|
||||
</span>
|
||||
)}
|
||||
{item.isTrainSupplementaire && (
|
||||
<span className="bg-[#6f42c1] text-white text-[11px] font-bold px-2 py-0.5 rounded-[4px] leading-none uppercase">
|
||||
Train Supplémentaire
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[12px] text-gray-500 font-normal mb-6">
|
||||
{item.operator || 'Opéré par SNCF Voyageurs - 1187'}
|
||||
</p>
|
||||
|
||||
{/* Information description box with full HTML support */}
|
||||
{item.perturbationDetails && (
|
||||
<div className="mb-6 bg-[#e8f4fc] border border-[#cbe4fc] rounded-xl p-4 text-[13px] text-[#222] max-w-[580px] shadow-2xs">
|
||||
<div className="flex items-center gap-2 font-bold text-[#0055a5] mb-2">
|
||||
<span className="w-4 h-4 rounded-full bg-[#0055a5] text-white flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
i
|
||||
</span>
|
||||
<span>Information :</span>
|
||||
</div>
|
||||
{/<[a-z][\s\S]*>/i.test(item.perturbationDetails) ? (
|
||||
<div
|
||||
className="text-[13px] leading-relaxed text-gray-800 space-y-2 [&_p]:mb-1.5 [&_p:last-child]:mb-0 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_a]:underline [&_a]:font-semibold"
|
||||
dangerouslySetInnerHTML={{ __html: item.perturbationDetails }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-[13px] whitespace-pre-line leading-relaxed text-gray-800">
|
||||
{item.perturbationDetails}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Perturbation Banner & Details in Timetable Accordion */}
|
||||
{hasInfo && (() => {
|
||||
const priority = getPerturbationPriority({
|
||||
isSupprime: item.isSupprime,
|
||||
hasParcoursModification: item.perturbationType === 'parcours_modifie' || item.perturbationType?.includes('parcours'),
|
||||
perturbationType: item.perturbationType,
|
||||
delayMinutes: item.revisedTime ? 1 : undefined,
|
||||
});
|
||||
if (priority === 'a_l_heure' && item.perturbationType !== 'information' && !item.perturbationDetails) return null;
|
||||
const bStyle = getPerturbationBannerStyle(priority === 'a_l_heure' ? 'information' : priority);
|
||||
const headerTitle = item.delayText || (
|
||||
priority === 'suppression'
|
||||
? 'Train supprimé'
|
||||
: priority === 'parcours_modifie'
|
||||
? 'Modification de parcours'
|
||||
: priority === 'retard'
|
||||
? 'Retard estimé'
|
||||
: 'Information trafic'
|
||||
);
|
||||
|
||||
{/* Table of stops with 100% continuous yellow line */}
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-xl p-4 border text-[13px] shadow-2xs max-w-[580px]"
|
||||
style={{
|
||||
backgroundColor: bStyle.bgColor,
|
||||
borderColor: `${bStyle.textColor}33`,
|
||||
color: bStyle.textColor,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-bold text-[14px] mb-1.5">
|
||||
<span
|
||||
className="w-5 h-5 rounded-full text-white flex items-center justify-center text-[10px] font-bold shrink-0"
|
||||
style={{ backgroundColor: bStyle.iconBgColor }}
|
||||
>
|
||||
{bStyle.iconText}
|
||||
</span>
|
||||
<span>{headerTitle}</span>
|
||||
</div>
|
||||
|
||||
{item.perturbationDetails && (
|
||||
<div className="mt-2 text-[12.5px] leading-relaxed text-gray-800">
|
||||
{/<[a-z][\s\S]*>/i.test(item.perturbationDetails) ? (
|
||||
<div
|
||||
className="space-y-1.5 [&_p]:mb-1 [&_p:last-child]:mb-0 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_a]:underline [&_a]:font-semibold"
|
||||
dangerouslySetInnerHTML={{ __html: item.perturbationDetails }}
|
||||
/>
|
||||
) : (
|
||||
<div className="whitespace-pre-line">
|
||||
{item.perturbationDetails}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Table of stops with 100% continuous yellow line (Matching Image 1) */}
|
||||
<div className="max-w-[560px] mb-8">
|
||||
|
||||
{/* Table Subheader */}
|
||||
@@ -1657,7 +1801,7 @@ export default function ProchainsDepartsClient({
|
||||
>
|
||||
{/* Column 1: Green circular radio selector ring */}
|
||||
<div className="flex items-center justify-start">
|
||||
{!isOrigin && (
|
||||
{!isOrigin && !stop.isSupprime && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom }))}
|
||||
@@ -1671,17 +1815,40 @@ export default function ProchainsDepartsClient({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column 2: Departure time */}
|
||||
<span
|
||||
onClick={() => {
|
||||
if (!isOrigin) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom }));
|
||||
}}
|
||||
className={`font-normal text-gray-900 ${!isOrigin ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
{stop.departTime || stop.arriveeTime}
|
||||
</span>
|
||||
{/* Column 2: Departure time (always displayed, struck-through if suppressed as requested) */}
|
||||
<div className="flex flex-col">
|
||||
{stop.isSupprime ? (
|
||||
<span className="line-through text-gray-500 font-normal text-[13px]">
|
||||
{stop.departTime || stop.arriveeTime}
|
||||
</span>
|
||||
) : stop.retardMinutes ? (
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="line-through text-gray-400 text-[11px]">
|
||||
{stop.departTime || stop.arriveeTime}
|
||||
</span>
|
||||
<span className="text-red-600 font-semibold text-[13px]">
|
||||
{(() => {
|
||||
const [sH, sM] = (stop.departTime || stop.arriveeTime || '00:00').split(':').map(Number);
|
||||
const total = sH * 60 + sM + stop.retardMinutes;
|
||||
const rH = Math.floor(total / 60) % 24;
|
||||
const rM = total % 60;
|
||||
return `${String(rH).padStart(2, '0')}:${String(rM).padStart(2, '0')}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => {
|
||||
if (!isOrigin && !stop.isSupprime) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom }));
|
||||
}}
|
||||
className={`font-normal text-gray-900 ${!isOrigin ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
{stop.departTime || stop.arriveeTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column 3: Solid Snap Dot */}
|
||||
{/* Column 3: Solid Snap Dot (Yellow #d2db24 uninterrupted, or primary if selected) */}
|
||||
<div className="flex items-center justify-center">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full relative z-10 shrink-0 transition-colors duration-150"
|
||||
@@ -1691,21 +1858,78 @@ export default function ProchainsDepartsClient({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column 4: Station name */}
|
||||
<span
|
||||
onClick={() => {
|
||||
if (!isOrigin) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom }));
|
||||
}}
|
||||
className={`font-normal text-gray-900 ${!isOrigin ? 'cursor-pointer hover:underline' : ''} ${
|
||||
isSelected ? 'font-semibold text-gray-950' : ''
|
||||
}`}
|
||||
>
|
||||
{stop.gareNom}
|
||||
</span>
|
||||
{/* Column 4: Station name & status (strictly conforming to Image 1) */}
|
||||
<div className="pr-2">
|
||||
{stop.isSupprime ? (
|
||||
<div>
|
||||
<span className="font-bold text-[14px] text-[#b91c1c] line-through block leading-snug">
|
||||
{stop.gareNom}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-[12px] font-bold text-[#b91c1c] mt-0.5">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-[#b91c1c] text-white flex items-center justify-center text-[9px] font-bold shrink-0">!</span>
|
||||
<span>Arrêt supprimé</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-700 mt-0.5 font-normal">
|
||||
{stop.perturbationDetails || "Accident à un passage à niveau"}
|
||||
</div>
|
||||
</div>
|
||||
) : stop.isNouveauTerminus ? (
|
||||
<div>
|
||||
<span
|
||||
onClick={() => {
|
||||
if (!isOrigin) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom }));
|
||||
}}
|
||||
className={`font-normal text-gray-900 block leading-snug ${!isOrigin ? 'cursor-pointer hover:underline' : ''} ${isSelected ? 'font-semibold text-gray-950' : ''}`}
|
||||
>
|
||||
{stop.gareNom}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[#0066a4] font-medium mt-0.5">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-[#0066a4] text-white flex items-center justify-center text-[9px] font-bold shrink-0">i</span>
|
||||
<span>Terminus exceptionnel</span>
|
||||
</div>
|
||||
</div>
|
||||
) : stop.isNouvelleOrigine ? (
|
||||
<div>
|
||||
<span className="font-normal text-gray-900 block leading-snug">
|
||||
{stop.gareNom}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[#0066a4] font-medium mt-0.5">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-[#0066a4] text-white flex items-center justify-center text-[9px] font-bold shrink-0">i</span>
|
||||
<span>Origine exceptionnelle</span>
|
||||
</div>
|
||||
</div>
|
||||
) : stop.isAddedManually ? (
|
||||
<div>
|
||||
<span
|
||||
onClick={() => {
|
||||
if (!isOrigin) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom }));
|
||||
}}
|
||||
className={`font-normal text-gray-900 block leading-snug ${!isOrigin ? 'cursor-pointer hover:underline' : ''} ${isSelected ? 'font-semibold text-gray-950' : ''}`}
|
||||
>
|
||||
{stop.gareNom}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-emerald-800 font-medium mt-0.5">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-emerald-700 text-white flex items-center justify-center text-[9px] font-bold shrink-0">+</span>
|
||||
<span>Arrêt exceptionnel</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span
|
||||
onClick={() => {
|
||||
if (!isOrigin) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom }));
|
||||
}}
|
||||
className={`font-normal text-gray-900 block leading-snug ${!isOrigin ? 'cursor-pointer hover:underline' : ''} ${isSelected ? 'font-semibold text-gray-950' : ''}`}
|
||||
>
|
||||
{stop.gareNom}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column 5: Voie */}
|
||||
<span className="font-normal text-gray-800 text-center">
|
||||
{stop.voie}
|
||||
{stop.isSupprime ? '-' : (stop.voie || '-')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -622,7 +622,7 @@ export default function RegionContent({
|
||||
.from('actualites')
|
||||
.select('*')
|
||||
.eq('actif', true)
|
||||
.in('region_id', regionCandidates)
|
||||
.or(`region_id.is.null,region_id.in.(${regionCandidates.join(',')})`)
|
||||
.is('deleted_at', null)
|
||||
.order('date_publication', { ascending: false })
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -635,7 +635,7 @@ export default function RegionContent({
|
||||
.from('evenements')
|
||||
.select('*')
|
||||
.eq('actif', true)
|
||||
.in('region_id', regionCandidates)
|
||||
.or(`region_id.is.null,region_id.in.(${regionCandidates.join(',')})`)
|
||||
.is('deleted_at', null)
|
||||
.order('date_debut', { ascending: false });
|
||||
if (eData) {
|
||||
|
||||
@@ -91,17 +91,6 @@ export default function SiteHeader({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(brandPrimary || brandSecondary) && (
|
||||
<div className="hidden sm:flex flex-col border-l border-gray-200 pl-3">
|
||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wider leading-none">
|
||||
{brandPrimary || "RÉGION"}
|
||||
</span>
|
||||
<span className="text-[13px] font-bold leading-tight" style={{ color: theme.primary }}>
|
||||
{brandSecondary || "BOURGOGNE-FRANCHE-COMTÉ"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@ import React, { useState, useEffect, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { getRegionTheme } from '@/lib/regionTheme';
|
||||
import { isPerturbationActiveOnDate, getPerturbationBannerStyle, formatPerturbationCause, getPerturbationPriority } from '@/lib/perturbationUtils';
|
||||
import { resolvePerturbedRoute } from '@/lib/parcoursUtils';
|
||||
import { isTrainActiveOnDate } from '@/lib/circulation';
|
||||
|
||||
export interface ResolvedDeparture {
|
||||
id: string | number;
|
||||
scheduledTime: string;
|
||||
revisedTime?: string;
|
||||
destName: string;
|
||||
trainType: string;
|
||||
trainNumber: string;
|
||||
@@ -15,6 +19,15 @@ export interface ResolvedDeparture {
|
||||
track: string;
|
||||
rawQuai?: string | null;
|
||||
sourceHoraire?: any;
|
||||
isSupprime?: boolean;
|
||||
isTrainSupplementaire?: boolean;
|
||||
statusLabel?: string;
|
||||
statusBannerStyle?: {
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
iconBgColor: string;
|
||||
iconText: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface StationDeparturesBoardProps {
|
||||
@@ -40,8 +53,18 @@ export default function StationDeparturesBoard({
|
||||
|
||||
const [selectedStation, setSelectedStation] = useState<string>(initialStation);
|
||||
const [horairesDataList, setHorairesDataList] = useState<any[]>([]);
|
||||
const [perturbationsList, setPerturbationsList] = useState<any[]>([]);
|
||||
const [garesTypeMap, setGaresTypeMap] = useState<Record<string, string>>({});
|
||||
const [expandedDepartures, setExpandedDepartures] = useState<boolean>(false);
|
||||
const [currentTime, setCurrentTime] = useState<Date>(() => new Date());
|
||||
|
||||
// Periodically refresh current time every 30 seconds to auto-hide departed trains
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Sync station if prop changes
|
||||
useEffect(() => {
|
||||
@@ -50,7 +73,7 @@ export default function StationDeparturesBoard({
|
||||
}
|
||||
}, [initialStation]);
|
||||
|
||||
// Fetch gares and horaires
|
||||
// Fetch gares, horaires and perturbations
|
||||
useEffect(() => {
|
||||
const fetchBoardData = async () => {
|
||||
try {
|
||||
@@ -83,6 +106,16 @@ export default function StationDeparturesBoard({
|
||||
gare_arrivee_nom: gMap[h.gare_arrivee_id] || '',
|
||||
})));
|
||||
}
|
||||
|
||||
const { data: pData } = await supabase
|
||||
.from('perturbations')
|
||||
.select('*')
|
||||
.eq('actif', true)
|
||||
.is('deleted_at', null);
|
||||
|
||||
if (pData) {
|
||||
setPerturbationsList(pData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching data for StationDeparturesBoard:', err);
|
||||
}
|
||||
@@ -100,89 +133,205 @@ export default function StationDeparturesBoard({
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const isTrainCirculating = (h: any, targetDate: string = getLocalDateStr()) => {
|
||||
if (h.actif === false) return false;
|
||||
if (Array.isArray(h.jours_personnalises) && h.jours_personnalises.length > 0) {
|
||||
const customDay = h.jours_personnalises.find((jp: any) => {
|
||||
const d = jp.date || jp.jour || '';
|
||||
return d === targetDate || d.slice(0, 10) === targetDate;
|
||||
});
|
||||
if (customDay) return customDay.circule === true;
|
||||
}
|
||||
return false;
|
||||
const getTomorrowDateStr = (d: Date = new Date()) => {
|
||||
const tom = new Date(d);
|
||||
tom.setDate(tom.getDate() + 1);
|
||||
return getLocalDateStr(tom);
|
||||
};
|
||||
|
||||
// Filter and resolve departures for selectedStation
|
||||
const stationDepartures: ResolvedDeparture[] = useMemo(() => {
|
||||
if (!selectedStation || horairesDataList.length === 0) return [];
|
||||
const isTrainCirculating = (h: any, targetDate: string = getLocalDateStr()) => {
|
||||
return isTrainActiveOnDate(h, targetDate);
|
||||
};
|
||||
|
||||
const selectedStationNorm = normalizeStationName(selectedStation);
|
||||
const todayDateStr = getLocalDateStr();
|
||||
const results: ResolvedDeparture[] = [];
|
||||
|
||||
for (const h of horairesDataList) {
|
||||
if (!isTrainCirculating(h, todayDateStr)) continue;
|
||||
|
||||
let gares: any[] = [];
|
||||
if (typeof h.gares_desservies === 'string') {
|
||||
try { gares = JSON.parse(h.gares_desservies); } catch (e) { gares = []; }
|
||||
} else if (Array.isArray(h.gares_desservies)) {
|
||||
gares = h.gares_desservies;
|
||||
}
|
||||
|
||||
const stopIndex = gares.findIndex(g => {
|
||||
const gNomNorm = normalizeStationName(g.gare_nom || '');
|
||||
return gNomNorm === selectedStationNorm ||
|
||||
gNomNorm.includes(selectedStationNorm) ||
|
||||
selectedStationNorm.includes(gNomNorm);
|
||||
});
|
||||
|
||||
if (stopIndex !== -1) {
|
||||
const stop = gares[stopIndex];
|
||||
const isTerminus = stop.is_arrivee || (stopIndex === gares.length - 1 && !stop.heure_depart) || !stop.heure_depart;
|
||||
|
||||
if (!isTerminus) {
|
||||
const lastStop = gares[gares.length - 1];
|
||||
const destination = h.gare_arrivee_nom || lastStop?.gare_nom || "Terminus";
|
||||
const departureTime = (stop.heure_depart || '').slice(0, 5);
|
||||
|
||||
// Track display logic
|
||||
const currentStationType = garesTypeMap[selectedStationNorm] || 'ville';
|
||||
const now = new Date();
|
||||
const [depH, depM] = departureTime.split(':').map(Number);
|
||||
const depMinutes = (isNaN(depH) ? 0 : depH) * 60 + (isNaN(depM) ? 0 : depM);
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const diffMinutes = depMinutes - currentMinutes;
|
||||
|
||||
let displayQuai = '-';
|
||||
if (stop.quai && stop.quai.trim() !== '') {
|
||||
if (currentStationType === 'ville') {
|
||||
if (diffMinutes <= 30) displayQuai = stop.quai;
|
||||
} else {
|
||||
if (diffMinutes <= 360) displayQuai = stop.quai;
|
||||
}
|
||||
}
|
||||
if (displayQuai === '-' && stop.quai) {
|
||||
displayQuai = stop.quai;
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: `${h.id}-${stopIndex}`,
|
||||
scheduledTime: departureTime,
|
||||
destName: destination,
|
||||
trainType: h.type_train || 'TRAIN MOBIGO',
|
||||
trainNumber: h.numero_train || '',
|
||||
trainLabel: `${h.type_train?.toUpperCase() || theme.operatorLabel || 'TER'} ${h.numero_train || ''}`.trim(),
|
||||
track: displayQuai,
|
||||
rawQuai: stop.quai,
|
||||
sourceHoraire: h,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Filter and resolve departures for selectedStation:
|
||||
// - If departure time is passed for today, hide it.
|
||||
// - If no departures remain today, display tomorrow's departures with "Plus aucun départ pour Aujourd'hui" info banner.
|
||||
const { departures: stationDepartures, isShowingTomorrow } = useMemo(() => {
|
||||
if (!selectedStation || horairesDataList.length === 0) {
|
||||
return { departures: [], isShowingTomorrow: false };
|
||||
}
|
||||
|
||||
return results.sort((a, b) => a.scheduledTime.localeCompare(b.scheduledTime));
|
||||
}, [selectedStation, horairesDataList, garesTypeMap, theme.operatorLabel]);
|
||||
const selectedStationNorm = normalizeStationName(selectedStation);
|
||||
const todayDateStr = getLocalDateStr(currentTime);
|
||||
const tomorrowDateStr = getTomorrowDateStr(currentTime);
|
||||
const nowTimeStr = `${String(currentTime.getHours()).padStart(2, '0')}:${String(currentTime.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
const resolveDeparturesForDate = (targetDateStr: string, isTomorrow: boolean = false): ResolvedDeparture[] => {
|
||||
const results: ResolvedDeparture[] = [];
|
||||
|
||||
// Collect active substitution horaire IDs for targetDateStr
|
||||
const substitutionHoraireIds = new Set<string>();
|
||||
perturbationsList.forEach((p: any) => {
|
||||
if (p.sillon_substitution_id && isPerturbationActiveOnDate(p, targetDateStr)) {
|
||||
substitutionHoraireIds.add(String(p.sillon_substitution_id));
|
||||
}
|
||||
});
|
||||
|
||||
for (const h of horairesDataList) {
|
||||
const isSub = substitutionHoraireIds.has(String(h.id)) || substitutionHoraireIds.has(String(h.numero_train));
|
||||
const isCirculating = isTrainCirculating(h, targetDateStr);
|
||||
|
||||
// Circulate if normally circulating OR if designated as a substitution train
|
||||
if (!isCirculating && !isSub) continue;
|
||||
|
||||
// Check for perturbations active strictly on this timetable's date (today or tomorrow)
|
||||
const matchingPert = perturbationsList.find((p: any) => {
|
||||
const trainMatch =
|
||||
p.horaire_id === h.id ||
|
||||
String(p.horaire_id) === String(h.id) ||
|
||||
String(p.horaire_id) === String(h.numero_train) ||
|
||||
(p.numero_train && String(p.numero_train) === String(h.numero_train));
|
||||
return trainMatch && isPerturbationActiveOnDate(p, targetDateStr);
|
||||
});
|
||||
|
||||
// Resolve real-world route considering active perturbations (nouveau terminus, nouvelle origine, arrêt supprimé)
|
||||
const resolvedParcours = resolvePerturbedRoute(h, matchingPert, {});
|
||||
const gares = resolvedParcours.stops;
|
||||
|
||||
const stopIndex = gares.findIndex(g => {
|
||||
const gNomNorm = normalizeStationName(g.gare_nom || '');
|
||||
return gNomNorm === selectedStationNorm ||
|
||||
gNomNorm.includes(selectedStationNorm) ||
|
||||
selectedStationNorm.includes(gNomNorm);
|
||||
});
|
||||
|
||||
if (stopIndex !== -1) {
|
||||
const stop = gares[stopIndex];
|
||||
const isTerminus = stop.is_nouveau_terminus || stop.is_arrivee || (stopIndex === gares.length - 1 && !stop.heure_depart) || !stop.heure_depart;
|
||||
|
||||
if (!isTerminus) {
|
||||
const destination = resolvedParcours.destination;
|
||||
const departureTime = (stop.heure_depart || stop.heure_arrivee || '').slice(0, 5);
|
||||
|
||||
// Track display logic
|
||||
const currentStationType = garesTypeMap[selectedStationNorm] || 'ville';
|
||||
const [depH, depM] = departureTime.split(':').map(Number);
|
||||
const depMinutes = (isNaN(depH) ? 0 : depH) * 60 + (isNaN(depM) ? 0 : depM);
|
||||
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||
const diffMinutes = depMinutes - currentMinutes;
|
||||
|
||||
let displayQuai = '-';
|
||||
const rawQ = stop.quai || stop.quai_reel;
|
||||
if (rawQ && rawQ.trim() !== '' && !stop.is_supprime) {
|
||||
if (isTomorrow) {
|
||||
displayQuai = rawQ;
|
||||
} else {
|
||||
if (currentStationType === 'ville') {
|
||||
if (diffMinutes <= 30) displayQuai = rawQ;
|
||||
} else {
|
||||
if (diffMinutes <= 360) displayQuai = rawQ;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (displayQuai === '-' && rawQ && !stop.is_supprime) {
|
||||
displayQuai = rawQ;
|
||||
}
|
||||
|
||||
let pertType = matchingPert?.type_perturbation;
|
||||
let delayMins: number | undefined = matchingPert?.duree_retard_minutes || undefined;
|
||||
let isSupp = pertType === 'suppression' || Boolean(stop.is_supprime);
|
||||
let pertCause = matchingPert?.cause || matchingPert?.cause_details || matchingPert?.titre;
|
||||
|
||||
if (stop.cause_arret) pertCause = stop.cause_arret;
|
||||
if (stop.retard_minutes !== undefined && stop.retard_minutes > 0) {
|
||||
delayMins = stop.retard_minutes;
|
||||
}
|
||||
|
||||
const priority = getPerturbationPriority({
|
||||
isSupprime: isSupp,
|
||||
hasParcoursModification: resolvedParcours.hasParcoursModification,
|
||||
perturbationType: pertType,
|
||||
typesPerturbation: stop.types_perturbation,
|
||||
delayMinutes: delayMins,
|
||||
});
|
||||
|
||||
let revisedTimeStr: string | undefined = undefined;
|
||||
let statusLabelStr: string | undefined = undefined;
|
||||
let bannerStyle: any = undefined;
|
||||
|
||||
if (priority === 'suppression') {
|
||||
isSupp = true;
|
||||
const formattedCause = formatPerturbationCause(pertCause);
|
||||
statusLabelStr = formattedCause ? `Train supprimé (${formattedCause})` : (stop.cause_arret || 'Train supprimé');
|
||||
bannerStyle = getPerturbationBannerStyle('suppression');
|
||||
} else if (priority === 'parcours_modifie') {
|
||||
isSupp = false;
|
||||
const formattedCause = formatPerturbationCause(matchingPert?.cause || pertCause);
|
||||
const extraRouteInfo = resolvedParcours.nouveauTerminusNom ? `Terminus ${resolvedParcours.nouveauTerminusNom}` : (resolvedParcours.nouvelleOrigineNom ? `Origine ${resolvedParcours.nouvelleOrigineNom}` : '');
|
||||
if (formattedCause && extraRouteInfo) {
|
||||
statusLabelStr = `Modification de parcours : ${extraRouteInfo} (${formattedCause})`;
|
||||
} else if (extraRouteInfo) {
|
||||
statusLabelStr = `Modification de parcours : ${extraRouteInfo}`;
|
||||
} else if (formattedCause) {
|
||||
statusLabelStr = `Modification de parcours (${formattedCause})`;
|
||||
} else {
|
||||
statusLabelStr = 'Modification de parcours';
|
||||
}
|
||||
bannerStyle = getPerturbationBannerStyle('parcours_modifie');
|
||||
} else if (priority === 'retard') {
|
||||
isSupp = false;
|
||||
const dMins = delayMins || 15;
|
||||
const formattedCause = formatPerturbationCause(matchingPert?.cause || pertCause);
|
||||
statusLabelStr = formattedCause ? `Retard estimé de ${dMins} min (${formattedCause})` : `Retard estimé de ${dMins} min`;
|
||||
bannerStyle = getPerturbationBannerStyle('retard');
|
||||
const [dH, dM] = departureTime.split(':').map(Number);
|
||||
const totalM = (isNaN(dH) ? 0 : dH) * 60 + (isNaN(dM) ? 0 : dM) + dMins;
|
||||
const rH = Math.floor(totalM / 60) % 24;
|
||||
const rM = totalM % 60;
|
||||
revisedTimeStr = `${String(rH).padStart(2, '0')}:${String(rM).padStart(2, '0')}`;
|
||||
} else if (matchingPert) {
|
||||
isSupp = false;
|
||||
const formattedCause = formatPerturbationCause(matchingPert?.cause || pertCause);
|
||||
statusLabelStr = formattedCause || matchingPert.titre || 'Information trafic';
|
||||
bannerStyle = getPerturbationBannerStyle('information');
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: `${h.id}-${stopIndex}`,
|
||||
scheduledTime: departureTime,
|
||||
revisedTime: revisedTimeStr,
|
||||
destName: destination,
|
||||
trainType: h.type_train || 'TRAIN MOBIGO',
|
||||
trainNumber: h.numero_train || '',
|
||||
trainLabel: `${h.type_train?.toUpperCase() || theme.operatorLabel || 'TER'} ${h.numero_train || ''}`.trim(),
|
||||
track: displayQuai,
|
||||
rawQuai: stop.quai || stop.quai_reel,
|
||||
sourceHoraire: h,
|
||||
isSupprime: isSupp,
|
||||
isTrainSupplementaire: isSub,
|
||||
statusLabel: statusLabelStr,
|
||||
statusBannerStyle: bannerStyle,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => a.scheduledTime.localeCompare(b.scheduledTime));
|
||||
};
|
||||
|
||||
// 1. Resolve today's departures
|
||||
const allTodayDepartures = resolveDeparturesForDate(todayDateStr, false);
|
||||
|
||||
// 2. Filter out departures whose departure time has already passed for today
|
||||
const activeTodayDepartures = allTodayDepartures.filter(dep => {
|
||||
const effectiveTime = dep.revisedTime || dep.scheduledTime;
|
||||
// Handle delay past midnight if applicable
|
||||
if (dep.revisedTime && dep.revisedTime < dep.scheduledTime) {
|
||||
if (nowTimeStr >= '12:00') return true;
|
||||
return dep.revisedTime >= nowTimeStr;
|
||||
}
|
||||
return effectiveTime >= nowTimeStr;
|
||||
});
|
||||
|
||||
// 3. If there are still departures today, display them
|
||||
if (activeTodayDepartures.length > 0) {
|
||||
return { departures: activeTodayDepartures, isShowingTomorrow: false };
|
||||
}
|
||||
|
||||
// 4. No remaining departures today -> resolve and display tomorrow's departures
|
||||
const tomorrowDepartures = resolveDeparturesForDate(tomorrowDateStr, true);
|
||||
return { departures: tomorrowDepartures, isShowingTomorrow: true };
|
||||
}, [selectedStation, horairesDataList, perturbationsList, garesTypeMap, theme.operatorLabel, currentTime]);
|
||||
|
||||
const defaultPillStations = availableStations || [
|
||||
"Dijon", "Besançon Viotte", "Belfort", "Chalon-sur-Saône"
|
||||
@@ -235,6 +384,16 @@ export default function StationDeparturesBoard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info banner when displaying tomorrow's departures because no departures remain today */}
|
||||
{isShowingTomorrow && stationDepartures.length > 0 && (
|
||||
<div className="bg-[#e8f4fc] px-3.5 py-2 text-[12px] text-[#0055a5] flex items-center gap-2 border border-[#d8ebf9] rounded-lg mb-3 font-medium">
|
||||
<span className="w-4 h-4 rounded-full bg-[#0055a5] text-white flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
i
|
||||
</span>
|
||||
<span>Plus aucun départ pour Aujourd'hui</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table header */}
|
||||
<div
|
||||
className="grid grid-cols-[55px_1fr_32px_40px] text-[11px] font-normal text-[#555555] pb-1 border-b-[1.5px]"
|
||||
@@ -252,59 +411,108 @@ export default function StationDeparturesBoard({
|
||||
}`}>
|
||||
{stationDepartures.length > 0 ? (
|
||||
stationDepartures.map((dep, dIdx) => (
|
||||
<div key={dep.id || dIdx} className="grid grid-cols-[55px_1fr_32px_40px] items-center py-2.5 hover:bg-gray-50/70 transition-colors rounded-sm px-0.5">
|
||||
{/* Time */}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[13.5px] font-semibold text-[#111111]">
|
||||
{dep.scheduledTime}
|
||||
<div key={dep.id || dIdx} className="py-2.5 hover:bg-gray-50/70 transition-colors rounded-sm px-0.5">
|
||||
<div className="grid grid-cols-[55px_1fr_32px_40px] items-center">
|
||||
{/* Time */}
|
||||
<div className="flex flex-col">
|
||||
{dep.isSupprime ? (
|
||||
<span className="text-[13.5px] font-semibold text-red-600 line-through">
|
||||
{dep.scheduledTime}
|
||||
</span>
|
||||
) : dep.revisedTime ? (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] text-gray-400 line-through leading-tight">
|
||||
{dep.scheduledTime}
|
||||
</span>
|
||||
<span className="text-[13.5px] font-bold text-red-600 leading-tight">
|
||||
{dep.revisedTime}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[13.5px] font-semibold text-[#111111]">
|
||||
{dep.scheduledTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="pr-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="font-semibold text-[13.5px] text-[#111111] leading-tight line-clamp-1">
|
||||
{dep.destName}
|
||||
</span>
|
||||
{dep.isTrainSupplementaire && (
|
||||
<span className="bg-[#6f42c1] text-white text-[9px] font-bold px-1.5 py-0.5 rounded leading-none shrink-0 tracking-wide uppercase">
|
||||
Train Supplémentaire
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center text-[11px] text-[#666666] mt-0.5">
|
||||
<span
|
||||
className="material-icons text-[13px] mr-1"
|
||||
style={{ color: theme.primary }}
|
||||
>
|
||||
train
|
||||
</span>
|
||||
<span className="truncate">{dep.trainLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voie / Quai */}
|
||||
<span className={`text-center font-medium text-[13px] ${
|
||||
dep.track !== '-' ? 'font-bold rounded-xs px-1 py-0.5' : 'text-[#666666]'
|
||||
}`}
|
||||
style={dep.track !== '-' ? { backgroundColor: theme.primaryLight, color: theme.primary } : undefined}
|
||||
>
|
||||
{dep.track}
|
||||
</span>
|
||||
|
||||
{/* Buy / Action Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (onAddToCart) {
|
||||
const ticketDate = new Date(currentTime);
|
||||
if (isShowingTomorrow) {
|
||||
ticketDate.setDate(ticketDate.getDate() + 1);
|
||||
}
|
||||
onAddToCart({
|
||||
id: Date.now() + dIdx,
|
||||
title: `Billet ${selectedStation} → ${dep.destName}`,
|
||||
price: 12.00,
|
||||
date: ticketDate.toLocaleDateString('fr-FR')
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded-full flex items-center justify-center gap-0.5 cursor-pointer transition-colors ml-auto shadow-2xs hover:opacity-90"
|
||||
style={{ backgroundColor: theme.primaryLight, color: theme.primary }}
|
||||
title={`Acheter un billet pour ${dep.destName}`}
|
||||
>
|
||||
<span className="material-icons text-[14px]">shopping_basket</span>
|
||||
<span className="font-bold text-[10px]">›</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="pr-1">
|
||||
<div className="font-semibold text-[13.5px] text-[#111111] leading-tight line-clamp-1">
|
||||
{dep.destName}
|
||||
</div>
|
||||
<div className="flex items-center text-[11px] text-[#666666] mt-0.5">
|
||||
{/* Status banner (retardé, supprimé, modification de parcours) */}
|
||||
{dep.statusLabel && dep.statusBannerStyle && (
|
||||
<div
|
||||
className="mt-1.5 px-2 py-1 rounded text-[11px] flex items-center gap-1.5 shadow-2xs font-medium"
|
||||
style={{
|
||||
backgroundColor: dep.statusBannerStyle.bgColor,
|
||||
color: dep.statusBannerStyle.textColor,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="material-icons text-[13px] mr-1"
|
||||
style={{ color: theme.primary }}
|
||||
className="w-3.5 h-3.5 rounded-full text-white flex items-center justify-center text-[9px] font-bold shrink-0"
|
||||
style={{ backgroundColor: dep.statusBannerStyle.iconBgColor }}
|
||||
>
|
||||
train
|
||||
{dep.statusBannerStyle.iconText}
|
||||
</span>
|
||||
<span className="truncate font-semibold">
|
||||
{dep.statusLabel}
|
||||
</span>
|
||||
<span className="truncate">{dep.trainLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voie / Quai */}
|
||||
<span className={`text-center font-medium text-[13px] ${
|
||||
dep.track !== '-' ? 'font-bold rounded-xs px-1 py-0.5' : 'text-[#666666]'
|
||||
}`}
|
||||
style={dep.track !== '-' ? { backgroundColor: theme.primaryLight, color: theme.primary } : undefined}
|
||||
>
|
||||
{dep.track}
|
||||
</span>
|
||||
|
||||
{/* Buy / Action Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (onAddToCart) {
|
||||
onAddToCart({
|
||||
id: Date.now() + dIdx,
|
||||
title: `Billet ${selectedStation} → ${dep.destName}`,
|
||||
price: 12.00,
|
||||
date: new Date().toLocaleDateString('fr-FR')
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded-full flex items-center justify-center gap-0.5 cursor-pointer transition-colors ml-auto shadow-2xs hover:opacity-90"
|
||||
style={{ backgroundColor: theme.primaryLight, color: theme.primary }}
|
||||
title={`Acheter un billet pour ${dep.destName}`}
|
||||
>
|
||||
<span className="material-icons text-[14px]">shopping_basket</span>
|
||||
<span className="font-bold text-[10px]">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -79,6 +79,16 @@ const DISCOUNT_CARDS = [
|
||||
|
||||
const TIME_SLOTS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
|
||||
|
||||
const formatDisplayDate = (d: Date): string => {
|
||||
const daysShort = ['Dim.', 'Lun.', 'Mar.', 'Mer.', 'Jeu.', 'Ven.', 'Sam.'];
|
||||
const monthsShort = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];
|
||||
return `${daysShort[d.getDay()]} ${d.getDate()} ${monthsShort[d.getMonth()]}`;
|
||||
};
|
||||
|
||||
const formatDisplayTime = (d: Date): string => {
|
||||
return `${String(d.getHours()).padStart(2, '0')}:00`;
|
||||
};
|
||||
|
||||
export default function TimetableSearchForm({
|
||||
initialDeparture = '',
|
||||
initialArrival = '',
|
||||
@@ -104,15 +114,19 @@ export default function TimetableSearchForm({
|
||||
|
||||
const theme = getRegionTheme(currentRegionSlug);
|
||||
|
||||
// Current date tracking
|
||||
const [selectedOutwardDate, setSelectedOutwardDate] = useState<Date>(() => new Date());
|
||||
const [selectedReturnDate, setSelectedReturnDate] = useState<Date>(() => new Date());
|
||||
|
||||
// Form states
|
||||
const [departureStation, setDepartureStation] = useState(initialDeparture);
|
||||
const [arrivalStation, setArrivalStation] = useState(initialArrival);
|
||||
const [viaStation, setViaStation] = useState('');
|
||||
const [showVia, setShowVia] = useState(false);
|
||||
const [outwardDate, setOutwardDate] = useState('Dim. 20 sept.');
|
||||
const [outwardTime, setOutwardTime] = useState('16:00');
|
||||
const [outwardDate, setOutwardDate] = useState(() => formatDisplayDate(new Date()));
|
||||
const [outwardTime, setOutwardTime] = useState(() => formatDisplayTime(new Date()));
|
||||
const [hasReturn, setHasReturn] = useState(false);
|
||||
const [returnDate, setReturnDate] = useState('Dim. 20 sept.');
|
||||
const [returnDate, setReturnDate] = useState(() => formatDisplayDate(new Date()));
|
||||
const [returnTime, setReturnTime] = useState('--:--');
|
||||
|
||||
// Interactive active field panel state
|
||||
@@ -121,15 +135,29 @@ export default function TimetableSearchForm({
|
||||
// Station database items
|
||||
const [allStations, setAllStations] = useState<string[]>(DEFAULT_STATIONS);
|
||||
|
||||
// Calendar state for date picker (default September 20, 2026)
|
||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date(2026, 8, 20)); // Month 8 is Sept
|
||||
const [selectedDayNumber, setSelectedDayNumber] = useState<number>(20);
|
||||
// Calendar state for date picker (synchronized with current date)
|
||||
const [calendarDate, setCalendarDate] = useState<Date>(() => {
|
||||
const n = new Date();
|
||||
return new Date(n.getFullYear(), n.getMonth(), 1);
|
||||
});
|
||||
const [selectedDayNumber, setSelectedDayNumber] = useState<number>(() => new Date().getDate());
|
||||
|
||||
// Passenger state matching Img 1
|
||||
const [passengers, setPassengers] = useState<PassengerInfo[]>([
|
||||
{ id: 1, age: '', discountCard: 'Carte de réduction (optionnel)' }
|
||||
]);
|
||||
|
||||
// Synchronize calendar view when switching active field
|
||||
useEffect(() => {
|
||||
if (activeField === 'outwardDate') {
|
||||
setCalendarDate(new Date(selectedOutwardDate.getFullYear(), selectedOutwardDate.getMonth(), 1));
|
||||
setSelectedDayNumber(selectedOutwardDate.getDate());
|
||||
} else if (activeField === 'returnDate') {
|
||||
setCalendarDate(new Date(selectedReturnDate.getFullYear(), selectedReturnDate.getMonth(), 1));
|
||||
setSelectedDayNumber(selectedReturnDate.getDate());
|
||||
}
|
||||
}, [activeField, selectedOutwardDate, selectedReturnDate]);
|
||||
|
||||
// Priority: URL searchParams > props > sessionStorage > default
|
||||
useEffect(() => {
|
||||
// 1. Try from URL search params
|
||||
@@ -344,7 +372,14 @@ export default function TimetableSearchForm({
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const firstDayOfWeek = (new Date(year, month, 1).getDay() + 6) % 7; // Mon = 0
|
||||
|
||||
const todayObj = new Date();
|
||||
todayObj.setHours(0, 0, 0, 0);
|
||||
const minMonthDate = new Date(todayObj.getFullYear(), todayObj.getMonth(), 1);
|
||||
const currentMonthDate = new Date(year, month, 1);
|
||||
const isPrevMonthDisabled = currentMonthDate <= minMonthDate;
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
if (isPrevMonthDisabled) return;
|
||||
setCalendarDate(new Date(year, month - 1, 1));
|
||||
};
|
||||
|
||||
@@ -355,13 +390,28 @@ export default function TimetableSearchForm({
|
||||
const handleSelectDay = (dayNum: number) => {
|
||||
setSelectedDayNumber(dayNum);
|
||||
const d = new Date(year, month, dayNum);
|
||||
const daysShort = ['Dim.', 'Lun.', 'Mar.', 'Mer.', 'Jeu.', 'Ven.', 'Sam.'];
|
||||
const monthsShort = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];
|
||||
const formatted = `${daysShort[d.getDay()]} ${dayNum} ${monthsShort[d.getMonth()]}`;
|
||||
const formatted = formatDisplayDate(d);
|
||||
|
||||
if (activeField === 'outwardDate') {
|
||||
setSelectedOutwardDate(d);
|
||||
setOutwardDate(formatted);
|
||||
|
||||
// If selected date is today and outwardTime is in the past, adjust outwardTime to current hour
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) {
|
||||
const [outH] = outwardTime.split(':').map(Number);
|
||||
if (outH < now.getHours()) {
|
||||
setOutwardTime(formatDisplayTime(now));
|
||||
}
|
||||
}
|
||||
|
||||
// If return date is earlier than outward date, bump return date
|
||||
if (hasReturn && selectedReturnDate < d) {
|
||||
setSelectedReturnDate(d);
|
||||
setReturnDate(formatted);
|
||||
}
|
||||
} else if (activeField === 'returnDate') {
|
||||
setSelectedReturnDate(d);
|
||||
setReturnDate(formatted);
|
||||
setHasReturn(true);
|
||||
}
|
||||
@@ -721,7 +771,13 @@ export default function TimetableSearchForm({
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
type="button"
|
||||
className="p-1 text-gray-400 hover:text-black transition-colors"
|
||||
disabled={isPrevMonthDisabled}
|
||||
className={`p-1 transition-colors ${
|
||||
isPrevMonthDisabled
|
||||
? 'text-gray-200 cursor-not-allowed opacity-30'
|
||||
: 'text-gray-400 hover:text-black cursor-pointer'
|
||||
}`}
|
||||
title={isPrevMonthDisabled ? 'Mois antérieur non sélectionnable' : 'Mois précédent'}
|
||||
>
|
||||
<span className="material-icons text-[18px]">chevron_left</span>
|
||||
</button>
|
||||
@@ -755,10 +811,15 @@ export default function TimetableSearchForm({
|
||||
const isSelected = dayNum === selectedDayNumber;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const sevenDaysAgo = new Date(today);
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const minAllowedDate = activeField === 'returnDate'
|
||||
? (new Date(selectedOutwardDate.getFullYear(), selectedOutwardDate.getMonth(), selectedOutwardDate.getDate()) > today
|
||||
? new Date(selectedOutwardDate.getFullYear(), selectedOutwardDate.getMonth(), selectedOutwardDate.getDate())
|
||||
: today)
|
||||
: today;
|
||||
|
||||
const dayDate = new Date(year, month, dayNum, 0, 0, 0, 0);
|
||||
const isPastDate = dayDate < sevenDaysAgo;
|
||||
const isPastDate = dayDate < minAllowedDate;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -774,7 +835,7 @@ export default function TimetableSearchForm({
|
||||
? 'font-bold shadow-xs scale-105'
|
||||
: 'hover:bg-gray-100 text-gray-800 cursor-pointer'
|
||||
}`}
|
||||
title={isPastDate ? 'Date passée' : undefined}
|
||||
title={isPastDate ? 'Date antérieure non sélectionnable' : undefined}
|
||||
>
|
||||
{dayNum}
|
||||
</button>
|
||||
@@ -797,9 +858,24 @@ export default function TimetableSearchForm({
|
||||
const isSelected = slot === currentSelected;
|
||||
|
||||
const now = new Date();
|
||||
const isTodaySelected = (year === now.getFullYear() && month === now.getMonth() && selectedDayNumber === now.getDate());
|
||||
const currentHour = now.getHours();
|
||||
const slotHour = parseInt(slot.split(':')[0], 10);
|
||||
const isPastTime = isTodaySelected && slotHour < now.getHours();
|
||||
|
||||
let isPastTime = false;
|
||||
if (activeField === 'outwardTime') {
|
||||
const isToday = selectedOutwardDate.toDateString() === now.toDateString();
|
||||
isPastTime = isToday && slotHour < currentHour;
|
||||
} else if (activeField === 'returnTime') {
|
||||
const isReturnToday = selectedReturnDate.toDateString() === now.toDateString();
|
||||
const isSameDayAsOutward = selectedReturnDate.toDateString() === selectedOutwardDate.toDateString();
|
||||
const outwardHour = parseInt(outwardTime.split(':')[0], 10);
|
||||
|
||||
if (isReturnToday && slotHour < currentHour) {
|
||||
isPastTime = true;
|
||||
} else if (isSameDayAsOutward && !isNaN(outwardHour) && slotHour < outwardHour) {
|
||||
isPastTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -819,7 +895,7 @@ export default function TimetableSearchForm({
|
||||
? 'font-bold cursor-pointer border-b'
|
||||
: 'border-b text-gray-900 hover:opacity-80 cursor-pointer'
|
||||
}`}
|
||||
title={isPastTime ? 'Heure passée' : undefined}
|
||||
title={isPastTime ? 'Heure passée non sélectionnable' : undefined}
|
||||
>
|
||||
{slot}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { getRegionTheme } from '@/lib/regionTheme';
|
||||
import SiteHeader from '@/components/SiteHeader';
|
||||
import SiteFooter from '@/components/SiteFooter';
|
||||
import { isPerturbationActiveOnDate, formatPerturbationCause, getPerturbationPriority, getPerturbationBannerStyle } from '@/lib/perturbationUtils';
|
||||
import { resolveEffectiveMateriel } from '@/lib/materielUtils';
|
||||
import { resolvePerturbedRoute } from '@/lib/parcoursUtils';
|
||||
|
||||
interface StopItem {
|
||||
gare_id?: string;
|
||||
@@ -15,6 +19,11 @@ interface StopItem {
|
||||
duree_arret?: string;
|
||||
is_depart?: boolean;
|
||||
is_arrivee?: boolean;
|
||||
is_nouveau_terminus?: boolean;
|
||||
is_nouvelle_origine?: boolean;
|
||||
is_added_manually?: boolean;
|
||||
is_supprime?: boolean;
|
||||
cause_arret?: string;
|
||||
}
|
||||
|
||||
interface TrainDetailsClientProps {
|
||||
@@ -34,11 +43,17 @@ export default function TrainDetailsClient({
|
||||
const [stops, setStops] = useState<StopItem[]>([]);
|
||||
const [delayMinutes, setDelayMinutes] = useState<number>(0);
|
||||
const [destination, setDestination] = useState<string>('Terminus');
|
||||
const [provenance, setProvenance] = useState<string>('Origine');
|
||||
const [operatorInfo, setOperatorInfo] = useState<string>('SNCF Voyageurs');
|
||||
const [trainType, setTrainType] = useState<string>('TER');
|
||||
|
||||
const [nouveauTerminusNom, setNouveauTerminusNom] = useState<string | null>(null);
|
||||
const [nouvelleOrigineNom, setNouvelleOrigineNom] = useState<string | null>(null);
|
||||
const [hasParcoursModifie, setHasParcoursModifie] = useState<boolean>(false);
|
||||
|
||||
const [materielInfo, setMaterielInfo] = useState<{ nom: string, nom_technique?: string, imageUrl: string, places: number, voitures: number } | null>(null);
|
||||
const [perturbationInfo, setPerturbationInfo] = useState<{ type: string, delayMinutes: number, message: string } | null>(null);
|
||||
const [perturbationInfo, setPerturbationInfo] = useState<{ type: string, delayMinutes: number, message: string; cause?: string; causeDetails?: string } | null>(null);
|
||||
const [substitutionInfo, setSubstitutionInfo] = useState<{ type: string, numero: string, depart: string, arrivee: string, heureDepart: string, heureArrivee: string } | null>(null);
|
||||
|
||||
const currentDateDisplay = dateStr || new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
|
||||
@@ -78,7 +93,18 @@ export default function TrainDetailsClient({
|
||||
setMenuStructure(mData[0].structure);
|
||||
}
|
||||
|
||||
// 2. Fetch train details
|
||||
// 2. Fetch gares for name mapping
|
||||
const { data: gDataAll } = await supabase.from('gares').select('id, nom, type');
|
||||
const gMap: Record<string, string> = {};
|
||||
const garesTypeMap: Record<string, string> = {};
|
||||
if (gDataAll) {
|
||||
gDataAll.forEach((g: any) => {
|
||||
gMap[g.id] = g.nom;
|
||||
garesTypeMap[g.id] = g.type || 'ville';
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Fetch train details
|
||||
const { data: hData } = await supabase
|
||||
.from('horaires')
|
||||
.select('*')
|
||||
@@ -91,155 +117,166 @@ export default function TrainDetailsClient({
|
||||
const h = hData[0];
|
||||
setTrainType(h.type_train?.toUpperCase() || 'TER');
|
||||
|
||||
if (h.gare_arrivee_nom) {
|
||||
setDestination(h.gare_arrivee_nom);
|
||||
} else if (h.gare_arrivee_id) {
|
||||
const { data: gArr } = await supabase
|
||||
.from('gares')
|
||||
.select('nom')
|
||||
.eq('id', h.gare_arrivee_id)
|
||||
.single();
|
||||
if (gArr) setDestination(gArr.nom);
|
||||
}
|
||||
|
||||
|
||||
// Fetch perturbations
|
||||
// Date format conversion
|
||||
const parts = currentDateDisplay.split('/');
|
||||
const targetIsoDate = parts.length === 3 ? `${parts[2]}-${parts[1]}-${parts[0]}` : new Date().toISOString().split('T')[0];
|
||||
|
||||
|
||||
// 4. Fetch perturbations active on this target date
|
||||
const { data: pertData } = await supabase.from('perturbations').select('*').eq('actif', true);
|
||||
let currentDelay = 0;
|
||||
let pArrets: any[] = [];
|
||||
|
||||
let matchingPert: any = null;
|
||||
|
||||
if (pertData) {
|
||||
const matchingPert = pertData.find((p: any) => {
|
||||
const matchTrain = p.horaire_id === h.id || p.horaire_id === h.numero_train;
|
||||
matchingPert = pertData.find((p: any) => {
|
||||
const matchTrain =
|
||||
p.horaire_id === h.id ||
|
||||
String(p.horaire_id) === String(h.id) ||
|
||||
String(p.horaire_id) === String(h.numero_train) ||
|
||||
(p.numero_train && String(p.numero_train) === String(h.numero_train));
|
||||
if (!matchTrain) return false;
|
||||
|
||||
const pStart = (p.date_debut || '').slice(0, 10);
|
||||
const pEnd = (p.date_fin || '').slice(0, 10);
|
||||
|
||||
if (pStart && pEnd && pStart <= targetIsoDate && targetIsoDate <= pEnd) return true;
|
||||
|
||||
if (p.jours_perturbation && Array.isArray(p.jours_perturbation)) {
|
||||
return p.jours_perturbation.some((jp: any) => {
|
||||
const d = (jp.date || jp.dateDebut || '').slice(0, 10);
|
||||
return d === targetIsoDate;
|
||||
});
|
||||
}
|
||||
return !pStart && !pEnd && (!p.jours_perturbation || p.jours_perturbation.length === 0);
|
||||
return isPerturbationActiveOnDate(p, targetIsoDate);
|
||||
});
|
||||
|
||||
if (matchingPert) {
|
||||
pArrets = Array.isArray(matchingPert.perturbations_arrets) ? matchingPert.perturbations_arrets : [];
|
||||
const pArrets = Array.isArray(matchingPert.perturbations_arrets)
|
||||
? matchingPert.perturbations_arrets
|
||||
: Array.isArray(matchingPert.perturbations_arrets?.arrets)
|
||||
? matchingPert.perturbations_arrets.arrets
|
||||
: [];
|
||||
|
||||
// Find max delay in perturbations_arrets
|
||||
let maxArretsDelay = 0;
|
||||
pArrets.forEach(pa => {
|
||||
pArrets.forEach((pa: any) => {
|
||||
if (pa.retard_cumule > maxArretsDelay) maxArretsDelay = pa.retard_cumule;
|
||||
if (pa.retard_entrant > maxArretsDelay) maxArretsDelay = pa.retard_entrant;
|
||||
});
|
||||
|
||||
currentDelay = maxArretsDelay > 0 ? maxArretsDelay : (matchingPert.duree_retard_minutes || 0);
|
||||
|
||||
const formattedCause = formatPerturbationCause(matchingPert.cause);
|
||||
const detailMsg = matchingPert.cause_details || matchingPert.notes || '';
|
||||
const displayMsg = formattedCause && detailMsg ? `${formattedCause} : ${detailMsg}` : (formattedCause || detailMsg);
|
||||
|
||||
setPerturbationInfo({
|
||||
type: matchingPert.type_perturbation || 'Retard',
|
||||
delayMinutes: currentDelay,
|
||||
message: matchingPert.cause_details || matchingPert.notes || '',
|
||||
message: displayMsg,
|
||||
cause: formattedCause,
|
||||
causeDetails: matchingPert.cause_details || matchingPert.notes || '',
|
||||
});
|
||||
setDelayMinutes(currentDelay);
|
||||
}
|
||||
}
|
||||
// Fetch materiel roulant
|
||||
if (h.composition_train && Array.isArray(h.composition_train) && h.composition_train.length > 0) {
|
||||
const firstMat = h.composition_train[0];
|
||||
if (firstMat.materiel_image_url) {
|
||||
setMaterielInfo({
|
||||
nom: firstMat.materiel_nom || 'Train',
|
||||
nom_technique: firstMat.materiel_nom_technique,
|
||||
imageUrl: firstMat.materiel_image_url,
|
||||
places: firstMat.materiel_nb_places || 163,
|
||||
voitures: firstMat.materiel_voitures || 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (h.gares_desservies) {
|
||||
let parsedStops: any[] = [];
|
||||
if (typeof h.gares_desservies === 'string') {
|
||||
try { parsedStops = JSON.parse(h.gares_desservies); } catch (e) { parsedStops = []; }
|
||||
} else if (Array.isArray(h.gares_desservies)) {
|
||||
parsedStops = h.gares_desservies;
|
||||
}
|
||||
// Fetch substitution train if defined
|
||||
if (matchingPert.sillon_substitution_id) {
|
||||
const { data: subHData } = await supabase
|
||||
.from('horaires')
|
||||
.select('*')
|
||||
.eq('id', matchingPert.sillon_substitution_id)
|
||||
.single();
|
||||
|
||||
if (parsedStops.length > 0) {
|
||||
// Fetch types for gares to apply quai logic
|
||||
const stopGareIds = parsedStops.map(s => s.gare_id).filter(Boolean);
|
||||
let garesTypeMap: Record<string, string> = {};
|
||||
if (stopGareIds.length > 0) {
|
||||
const { data: gData } = await supabase.from('gares').select('id, type').in('id', stopGareIds);
|
||||
if (gData) {
|
||||
gData.forEach(g => {
|
||||
garesTypeMap[g.id] = g.type || 'ville';
|
||||
if (subHData) {
|
||||
let subStops: any[] = [];
|
||||
if (typeof subHData.gares_desservies === 'string') {
|
||||
try { subStops = JSON.parse(subHData.gares_desservies); } catch (e) { subStops = []; }
|
||||
} else if (Array.isArray(subHData.gares_desservies)) {
|
||||
subStops = subHData.gares_desservies;
|
||||
}
|
||||
const sDepNom = subHData.gare_depart_nom || subStops[0]?.gare_nom || gMap[subHData.gare_depart_id] || 'Départ';
|
||||
const sArrNom = subHData.gare_arrivee_nom || subStops[subStops.length - 1]?.gare_nom || gMap[subHData.gare_arrivee_id] || 'Arrivée';
|
||||
const sDepH = (subHData.heure_depart || subStops[0]?.heure_depart || '').slice(0, 5);
|
||||
const sArrH = (subHData.heure_arrivee || subStops[subStops.length - 1]?.heure_arrivee || '').slice(0, 5);
|
||||
|
||||
setSubstitutionInfo({
|
||||
type: subHData.type_train || 'Car MOBIGO',
|
||||
numero: subHData.numero_train || '',
|
||||
depart: sDepNom,
|
||||
arrivee: sArrNom,
|
||||
heureDepart: sDepH,
|
||||
heureArrivee: sArrH,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
|
||||
const resolved: StopItem[] = parsedStops.map((s, idx) => {
|
||||
const prev = s.heure_depart || s.heure_arrivee || '12:00';
|
||||
let est = prev;
|
||||
|
||||
// Get delay specifically for this stop
|
||||
const stopPert = pArrets.find(pa => pa.gare_nom === s.gare_nom || pa.gare_id === s.gare_id);
|
||||
const stopDelay = stopPert && stopPert.retard_cumule !== undefined ? stopPert.retard_cumule : currentDelay;
|
||||
|
||||
if (stopDelay > 0) {
|
||||
const [hh, mm] = prev.split(':').map(Number);
|
||||
const totalMin = (hh * 60 + mm + stopDelay) % 1440;
|
||||
const newH = String(Math.floor(totalMin / 60)).padStart(2, '0');
|
||||
const newM = String(totalMin % 60).padStart(2, '0');
|
||||
est = `${newH}:${newM}`;
|
||||
}
|
||||
|
||||
|
||||
// Logic for displaying Quai/Voie
|
||||
let displayVoie = undefined;
|
||||
if (s.quai) {
|
||||
const type = s.gare_id ? garesTypeMap[s.gare_id] || 'ville' : 'ville';
|
||||
const [depH, depM] = prev.split(':').map(Number);
|
||||
const depMinutes = (isNaN(depH) ? 0 : depH) * 60 + (isNaN(depM) ? 0 : depM);
|
||||
const diffMinutes = depMinutes - currentMinutes;
|
||||
|
||||
if (type === 'ville') {
|
||||
if (diffMinutes <= 30) displayVoie = `Voie ${s.quai}`;
|
||||
} else if (type === 'interurbaine') {
|
||||
if (diffMinutes <= 360) displayVoie = `Voie ${s.quai}`;
|
||||
} else {
|
||||
if (diffMinutes <= 1440) displayVoie = `Voie ${s.quai}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gare_nom: s.gare_nom || `Arrêt ${idx + 1}`,
|
||||
heure_prevue: prev.slice(0, 5),
|
||||
heure_estimee: est.slice(0, 5),
|
||||
voie: displayVoie,
|
||||
duree_arret: idx > 0 && idx < parsedStops.length - 1 ? "30 secondes d'arrêt" : undefined,
|
||||
is_depart: idx === 0,
|
||||
is_arrivee: idx === parsedStops.length - 1,
|
||||
};
|
||||
});
|
||||
|
||||
setStops(resolved);
|
||||
if (!h.gare_arrivee_nom && !h.gare_arrivee_id) {
|
||||
setDestination(resolved[resolved.length - 1].gare_nom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Resolve real-world route considering perturbation (nouveau terminus, nouvelle origine, arrêt supprimé, retards)
|
||||
const resolvedParcours = resolvePerturbedRoute(h, matchingPert, gMap);
|
||||
setDestination(resolvedParcours.destination);
|
||||
setProvenance(resolvedParcours.provenance);
|
||||
setNouveauTerminusNom(resolvedParcours.nouveauTerminusNom || null);
|
||||
setNouvelleOrigineNom(resolvedParcours.nouvelleOrigineNom || null);
|
||||
setHasParcoursModifie(resolvedParcours.hasParcoursModification);
|
||||
|
||||
// 6. Fetch and resolve effective rolling stock (handling unavailable matériel & reassignments)
|
||||
const { data: allMats } = await supabase.from('materiel-roulant').select('*');
|
||||
const effectiveMat = resolveEffectiveMateriel(h.composition_train, allMats || [], {
|
||||
modeleTrain: h.modele_train || 'Train',
|
||||
capaciteTexte: h.capacite_texte,
|
||||
imageUrl: h.image_materiel || undefined
|
||||
});
|
||||
|
||||
if (effectiveMat.imageUrl) {
|
||||
setMaterielInfo({
|
||||
nom: effectiveMat.nom,
|
||||
nom_technique: effectiveMat.nomTechnique,
|
||||
imageUrl: effectiveMat.imageUrl,
|
||||
places: effectiveMat.places,
|
||||
voitures: effectiveMat.voitures,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Resolve timeline stops from resolvedParcours
|
||||
const now = new Date();
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const resolved: StopItem[] = resolvedParcours.stops.map((s, idx) => {
|
||||
const prev = s.heure_depart || s.heure_arrivee || '12:00';
|
||||
let est = prev;
|
||||
|
||||
const stopDelay = s.retard_cumule !== undefined ? s.retard_cumule : (s.retard_minutes !== undefined ? s.retard_minutes : currentDelay);
|
||||
if (stopDelay > 0 && !s.is_supprime) {
|
||||
const [hh, mm] = prev.split(':').map(Number);
|
||||
const totalMin = (hh * 60 + mm + stopDelay) % 1440;
|
||||
const newH = String(Math.floor(totalMin / 60)).padStart(2, '0');
|
||||
const newM = String(totalMin % 60).padStart(2, '0');
|
||||
est = `${newH}:${newM}`;
|
||||
}
|
||||
|
||||
// Logic for displaying Quai/Voie
|
||||
let displayVoie = undefined;
|
||||
const rawQ = s.quai || s.quai_reel;
|
||||
if (rawQ && !s.is_supprime) {
|
||||
const type = s.gare_id ? garesTypeMap[s.gare_id] || 'ville' : 'ville';
|
||||
const [depH, depM] = prev.split(':').map(Number);
|
||||
const depMinutes = (isNaN(depH) ? 0 : depH) * 60 + (isNaN(depM) ? 0 : depM);
|
||||
const diffMinutes = depMinutes - currentMinutes;
|
||||
|
||||
if (type === 'ville') {
|
||||
if (diffMinutes <= 30) displayVoie = `Voie ${rawQ}`;
|
||||
} else if (type === 'interurbaine') {
|
||||
if (diffMinutes <= 360) displayVoie = `Voie ${rawQ}`;
|
||||
} else {
|
||||
if (diffMinutes <= 1440) displayVoie = `Voie ${rawQ}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gare_id: s.gare_id,
|
||||
gare_nom: s.gare_nom || `Arrêt ${idx + 1}`,
|
||||
heure_prevue: prev.slice(0, 5),
|
||||
heure_estimee: est.slice(0, 5),
|
||||
voie: displayVoie,
|
||||
duree_arret: idx > 0 && idx < resolvedParcours.stops.length - 1 && !s.is_supprime ? "30 secondes d'arrêt" : undefined,
|
||||
is_depart: s.is_depart,
|
||||
is_arrivee: s.is_arrivee,
|
||||
is_nouveau_terminus: s.is_nouveau_terminus,
|
||||
is_nouvelle_origine: s.is_nouvelle_origine,
|
||||
is_added_manually: s.is_added_manually,
|
||||
is_supprime: s.is_supprime,
|
||||
cause_arret: s.cause_arret,
|
||||
};
|
||||
});
|
||||
|
||||
setStops(resolved);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading train details:', err);
|
||||
@@ -247,7 +284,7 @@ export default function TrainDetailsClient({
|
||||
};
|
||||
|
||||
fetchTrainDetails();
|
||||
}, [trainNumber, regionSlug]);
|
||||
}, [trainNumber, regionSlug, currentDateDisplay]);
|
||||
|
||||
const darkenColor = (hex: string, amount: number) => {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
@@ -260,6 +297,8 @@ export default function TrainDetailsClient({
|
||||
const darkBg = darkenColor(theme.primary, 60);
|
||||
|
||||
const isSuppressed = perturbationInfo?.type?.toLowerCase() === 'suppression';
|
||||
const isParcoursModifie = hasParcoursModifie || perturbationInfo?.type === 'modification_parcours';
|
||||
const activeServedStopsCount = stops.filter(s => !s.is_supprime).length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-gray-900 font-sans flex flex-col justify-between" style={{ backgroundColor: '#f0f3f8' }}>
|
||||
@@ -268,7 +307,7 @@ export default function TrainDetailsClient({
|
||||
<SiteHeader regionSlug={regionSlug} menuStructure={menuStructure} />
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* SECTION 1: Dark theme header area (Image 2 style) */}
|
||||
{/* SECTION 1: Dark theme header area */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div style={{ backgroundColor: darkBg }}>
|
||||
<div className="py-3.5 px-4" style={{ backgroundColor: darkBg }}>
|
||||
@@ -308,7 +347,7 @@ export default function TrainDetailsClient({
|
||||
<svg viewBox="0 0 24 24" className="w-[14px] h-[14px] fill-current opacity-80" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
|
||||
</svg>
|
||||
{stops.length} arrêts
|
||||
{activeServedStopsCount} arrêts desservis
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg viewBox="0 0 24 24" className="w-[15px] h-[15px] fill-current opacity-80" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -440,47 +479,122 @@ export default function TrainDetailsClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── CARD: PERTURBATION (Retard ou Suppression) ─── */}
|
||||
{perturbationInfo && (
|
||||
<div className={`bg-white rounded-2xl p-4 shadow-sm border flex items-center justify-between gap-3 mt-4 ${isSuppressed ? 'border-red-200' : 'border-gray-100'}`}>
|
||||
{/* ─── CARD: PERTURBATION (Strict priority: Supprimé > Modification de parcours > Retardé > A l'heure) ─── */}
|
||||
{perturbationInfo && (() => {
|
||||
const priority = getPerturbationPriority({
|
||||
isSupprime: isSuppressed,
|
||||
hasParcoursModification: isParcoursModifie,
|
||||
perturbationType: perturbationInfo.type,
|
||||
delayMinutes: delayMinutes,
|
||||
});
|
||||
|
||||
if (priority === 'a_l_heure' && !perturbationInfo.causeDetails) return null;
|
||||
|
||||
const isSupp = priority === 'suppression';
|
||||
const isModif = priority === 'parcours_modifie';
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-2xl p-4 shadow-sm border flex items-center justify-between gap-3 mt-4 ${
|
||||
isSupp ? 'border-red-200' : isModif ? 'border-blue-200' : 'border-orange-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-6 h-6 rounded-full text-white flex items-center justify-center shrink-0 ${
|
||||
isSupp ? 'bg-red-600' : isModif ? 'bg-[#0066a4]' : 'bg-[#e65100]'
|
||||
}`}>
|
||||
{isSupp ? (
|
||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
</svg>
|
||||
) : isModif ? (
|
||||
<span className="text-[13px] font-bold leading-none">↔</span>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm4.2 14.2L11 13V7h1.5v5.2l4.5 2.7-.8 1.3z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-bold text-[13px] leading-tight ${
|
||||
isSupp ? 'text-red-700' : isModif ? 'text-[#0066a4]' : 'text-[#9a3412]'
|
||||
}`}>
|
||||
{isSupp
|
||||
? (perturbationInfo.cause ? `Train supprimé (${perturbationInfo.cause})` : 'Train supprimé')
|
||||
: isModif
|
||||
? (nouveauTerminusNom
|
||||
? `Modification de parcours : Terminus ${nouveauTerminusNom}${perturbationInfo.cause ? ` (${perturbationInfo.cause})` : ''}`
|
||||
: `Modification de parcours${perturbationInfo.cause ? ` (${perturbationInfo.cause})` : ''}`)
|
||||
: (perturbationInfo.cause
|
||||
? `Retard estimé à ${delayMinutes} min (${perturbationInfo.cause})`
|
||||
: `Retard estimé à ${delayMinutes} min`)}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-600 mt-0.5">
|
||||
Train {trainType} {trainNumber}
|
||||
{nouveauTerminusNom && ` • Nouveau terminus exceptionnel : ${nouveauTerminusNom}`}
|
||||
{nouvelleOrigineNom && ` • Nouvelle origine : ${nouvelleOrigineNom}`}
|
||||
{perturbationInfo.causeDetails && ` • ${perturbationInfo.causeDetails}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-[#0070e0] font-bold text-[18px] select-none">›</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ─── CARD: SILLON DE SUBSTITUTION (SI DEFINI) ─── */}
|
||||
{substitutionInfo && (
|
||||
<div className="bg-[#f5f0ff] rounded-2xl p-4 shadow-sm border border-purple-200 flex items-center justify-between gap-3 mt-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-6 h-6 rounded-full text-white flex items-center justify-center shrink-0 ${isSuppressed ? 'bg-red-600' : 'bg-[#9a3412]'}`}>
|
||||
{isSuppressed ? (
|
||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm4.2 14.2L11 13V7h1.5v5.2l4.5 2.7-.8 1.3z" />
|
||||
</svg>
|
||||
)}
|
||||
<div className="w-6 h-6 rounded-full bg-[#6f42c1] text-white flex items-center justify-center shrink-0">
|
||||
<span className="material-icons text-[14px]">directions_bus</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-bold text-[13px] leading-tight ${isSuppressed ? 'text-red-700' : 'text-[#9a3412]'}`}>
|
||||
{isSuppressed ? 'Train supprimé' : `Retard estimé à ${delayMinutes} min`}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-[13px] text-purple-950">
|
||||
{substitutionInfo.type} {substitutionInfo.numero}
|
||||
</span>
|
||||
<span className="bg-[#6f42c1] text-white text-[9.5px] font-bold px-1.5 py-0.5 rounded leading-none uppercase">
|
||||
Train Supplémentaire
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-600 mt-0.5">
|
||||
Train {trainType} {trainNumber}
|
||||
<div className="text-[11.5px] text-purple-800 mt-0.5">
|
||||
Substitution : {substitutionInfo.depart} ({substitutionInfo.heureDepart}) → {substitutionInfo.arrivee} ({substitutionInfo.heureArrivee})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-[#0070e0] font-bold text-[18px] select-none">›</span>
|
||||
<Link
|
||||
href={`/train/${substitutionInfo.numero}`}
|
||||
className="text-[#6f42c1] hover:underline font-bold text-[12px] shrink-0"
|
||||
>
|
||||
Voir ›
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── DESTINATION & OPÉRATEUR ─── */}
|
||||
<div className="pt-5 px-1">
|
||||
<h2 className="text-[14px] font-bold text-gray-900 leading-tight">
|
||||
Destination {destination}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-[14px] font-bold text-gray-900 leading-tight">
|
||||
Destination {destination}
|
||||
</h2>
|
||||
{nouveauTerminusNom && (
|
||||
<span className="text-[10.5px] font-bold px-2 py-0.5 rounded bg-amber-100 text-amber-900 border border-amber-300">
|
||||
Nouveau terminus
|
||||
</span>
|
||||
)}
|
||||
{nouvelleOrigineNom && (
|
||||
<span className="text-[10.5px] font-bold px-2 py-0.5 rounded bg-blue-100 text-blue-900 border border-blue-300">
|
||||
Nouvelle origine
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11.5px] text-gray-500 mt-1">
|
||||
Opéré par {operatorInfo}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* TIMELINE VERTICALE */}
|
||||
{/* TIMELINE VERTICALE DES ARRÊTS */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="relative pt-6 pb-8">
|
||||
<div
|
||||
@@ -488,21 +602,29 @@ export default function TrainDetailsClient({
|
||||
style={{ backgroundColor: isSuppressed ? '#e5e7eb' : theme.primary, left: '82px' }}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col" style={{ gap: '36px' }}>
|
||||
<div className="flex flex-col" style={{ gap: '32px' }}>
|
||||
{stops.map((stop, idx) => {
|
||||
const isDelayed = !isSuppressed && delayMinutes > 0 && stop.heure_estimee !== stop.heure_prevue;
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === stops.length - 1;
|
||||
const isStopSuppressed = isSuppressed || Boolean(stop.is_supprime);
|
||||
const isDelayed = !isStopSuppressed && delayMinutes > 0 && stop.heure_estimee !== stop.heure_prevue;
|
||||
const isFirst = stop.is_depart || idx === 0;
|
||||
const isLast = stop.is_arrivee || stop.is_nouveau_terminus || idx === stops.length - 1;
|
||||
|
||||
return (
|
||||
<div key={idx} className={`relative flex items-start ${isSuppressed ? 'opacity-60' : ''}`}>
|
||||
<div key={idx} className={`relative flex items-start ${isStopSuppressed ? 'opacity-75' : ''}`}>
|
||||
|
||||
{/* Column 1: Time pill */}
|
||||
<div className="w-[72px] shrink-0 text-right pr-3 pt-px">
|
||||
<span
|
||||
className="inline-block px-2.5 py-[5px] rounded-full font-bold text-[12.5px] text-white leading-none tracking-tight"
|
||||
style={{ backgroundColor: isSuppressed ? '#9ca3af' : theme.primary }}
|
||||
style={{
|
||||
backgroundColor: isStopSuppressed
|
||||
? '#9ca3af'
|
||||
: stop.is_nouveau_terminus
|
||||
? '#0066a4'
|
||||
: theme.primary
|
||||
}}
|
||||
>
|
||||
{isSuppressed ? stop.heure_prevue : (stop.heure_estimee || stop.heure_prevue)}
|
||||
{isStopSuppressed ? stop.heure_prevue : (stop.heure_estimee || stop.heure_prevue)}
|
||||
</span>
|
||||
{isDelayed && (
|
||||
<span className="block text-[10px] text-gray-400 line-through mt-1 pr-1 font-normal">
|
||||
@@ -511,41 +633,96 @@ export default function TrainDetailsClient({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column 2: Timeline circular dot */}
|
||||
<div className="w-[24px] shrink-0 flex justify-center pt-[3px] relative z-10">
|
||||
<div
|
||||
className="w-[13px] h-[13px] rounded-full bg-white border-[3px] shadow-sm"
|
||||
style={{ borderColor: isSuppressed ? '#9ca3af' : theme.primary }}
|
||||
style={{
|
||||
borderColor: isStopSuppressed
|
||||
? '#b91c1c'
|
||||
: stop.is_nouveau_terminus
|
||||
? '#0066a4'
|
||||
: theme.primary
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column 3: Station Name & Badges (Matching Image 1) */}
|
||||
<div className="flex-1 pl-2 pt-0">
|
||||
<div className={`font-bold text-[15px] leading-tight ${isSuppressed ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
|
||||
{stop.gare_nom}
|
||||
</div>
|
||||
{isStopSuppressed ? (
|
||||
<div>
|
||||
<span className="font-bold text-[15px] leading-tight text-[#b91c1c] line-through block">
|
||||
{stop.gare_nom}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-[12px] font-bold text-[#b91c1c] mt-0.5">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-[#b91c1c] text-white flex items-center justify-center text-[9px] font-bold shrink-0">!</span>
|
||||
<span>Arrêt supprimé</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-700 mt-0.5 font-normal">
|
||||
{stop.cause_arret || perturbationInfo?.cause || "Accident à un passage à niveau"}
|
||||
</div>
|
||||
</div>
|
||||
) : stop.is_nouveau_terminus ? (
|
||||
<div>
|
||||
<span className="font-bold text-[15px] leading-tight text-gray-900 block">
|
||||
{stop.gare_nom}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[#0066a4] font-medium mt-0.5">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-[#0066a4] text-white flex items-center justify-center text-[9px] font-bold shrink-0">i</span>
|
||||
<span>Terminus exceptionnel</span>
|
||||
</div>
|
||||
</div>
|
||||
) : stop.is_nouvelle_origine ? (
|
||||
<div>
|
||||
<span className="font-bold text-[15px] leading-tight text-gray-900 block">
|
||||
{stop.gare_nom}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[#0066a4] font-medium mt-0.5">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-[#0066a4] text-white flex items-center justify-center text-[9px] font-bold shrink-0">i</span>
|
||||
<span>Origine exceptionnelle</span>
|
||||
</div>
|
||||
</div>
|
||||
) : stop.is_added_manually ? (
|
||||
<div>
|
||||
<span className="font-bold text-[15px] leading-tight text-gray-900 block">
|
||||
{stop.gare_nom}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-emerald-800 font-medium mt-0.5">
|
||||
<span className="w-3.5 h-3.5 rounded-full bg-emerald-700 text-white flex items-center justify-center text-[9px] font-bold shrink-0">+</span>
|
||||
<span>Arrêt exceptionnel</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-bold text-[15px] leading-tight text-gray-900 block">
|
||||
{stop.gare_nom}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stop.duree_arret && !isFirst && !isLast && (
|
||||
{stop.duree_arret && !isFirst && !isLast && !isStopSuppressed && (
|
||||
<div className="text-[11.5px] text-gray-400 mt-1 font-normal italic">
|
||||
{stop.duree_arret}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stop.voie && !isSuppressed && (
|
||||
{stop.voie && !isStopSuppressed && (
|
||||
<div
|
||||
className="text-[11.5px] font-semibold mt-0.5 inline-block px-2 py-0.5 rounded bg-gray-100"
|
||||
className="text-[11.5px] font-semibold mt-1 inline-block px-2 py-0.5 rounded bg-gray-100"
|
||||
style={{ color: theme.primary }}
|
||||
>
|
||||
{stop.voie}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFirst && (
|
||||
{isFirst && !isStopSuppressed && (
|
||||
<div className="text-[11px] text-gray-400 mt-1 font-normal">
|
||||
Départ
|
||||
{stop.is_nouvelle_origine ? 'Départ (Nouvelle origine)' : 'Départ'}
|
||||
</div>
|
||||
)}
|
||||
{isLast && (
|
||||
{isLast && !isStopSuppressed && (
|
||||
<div className="text-[11px] text-gray-400 mt-1 font-normal">
|
||||
Arrivée
|
||||
{stop.is_nouveau_terminus ? 'Arrivée (Nouveau terminus)' : 'Arrivée'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -560,18 +737,30 @@ export default function TrainDetailsClient({
|
||||
{perturbationInfo?.message && (
|
||||
<div className="w-full lg:w-[320px] shrink-0">
|
||||
<div className="sticky top-6">
|
||||
<div className={`rounded-xl p-5 shadow-sm border ${isSuppressed ? 'bg-red-50 border-red-200' : 'bg-orange-50 border-orange-200'}`}>
|
||||
<div className={`rounded-xl p-5 shadow-sm border ${
|
||||
isSuppressed ? 'bg-red-50 border-red-200' : isParcoursModifie ? 'bg-blue-50 border-blue-200' : 'bg-orange-50 border-orange-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-8 h-8 rounded-full text-white flex items-center justify-center shrink-0 ${isSuppressed ? 'bg-red-600' : 'bg-orange-600'}`}>
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4 fill-current" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
<div className={`w-8 h-8 rounded-full text-white flex items-center justify-center shrink-0 ${
|
||||
isSuppressed ? 'bg-red-600' : isParcoursModifie ? 'bg-[#0066a4]' : 'bg-orange-600'
|
||||
}`}>
|
||||
{isParcoursModifie ? (
|
||||
<span className="text-[15px] font-bold">↔</span>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4 fill-current" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h3 className={`font-bold text-[15px] ${isSuppressed ? 'text-red-900' : 'text-orange-900'}`}>
|
||||
Info Trafic
|
||||
<h3 className={`font-bold text-[15px] ${
|
||||
isSuppressed ? 'text-red-900' : isParcoursModifie ? 'text-blue-900' : 'text-orange-900'
|
||||
}`}>
|
||||
{isParcoursModifie ? 'Modification de parcours' : 'Info Trafic'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className={`text-[13px] leading-relaxed whitespace-pre-wrap ${isSuppressed ? 'text-red-800' : 'text-orange-800'}`}>
|
||||
<p className={`text-[13px] leading-relaxed whitespace-pre-wrap ${
|
||||
isSuppressed ? 'text-red-800' : isParcoursModifie ? 'text-blue-900' : 'text-orange-800'
|
||||
}`}>
|
||||
{perturbationInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export interface MaterielRoulantRow {
|
||||
id: number | string;
|
||||
nom?: string;
|
||||
nom_technique?: string;
|
||||
capacite?: number;
|
||||
image_url?: string;
|
||||
statut_maintenance?: string; // 'OPERATIONNEL' | 'EN_AVARIE_ATELIER' | 'MAINTENANCE_PROGRAMMEE'
|
||||
type_train?: string;
|
||||
numero_serie?: string;
|
||||
depot_attache?: string;
|
||||
}
|
||||
|
||||
export interface EffectiveMaterielResult {
|
||||
materielId?: number | string;
|
||||
nom: string;
|
||||
nomTechnique?: string;
|
||||
imageUrl?: string;
|
||||
places: number;
|
||||
voitures: number;
|
||||
isReassigned?: boolean;
|
||||
statutMaintenance?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective rolling stock to display for a train schedule.
|
||||
* If the original rolling stock is unavailable (defined in roster management: statut_maintenance !== 'OPERATIONNEL')
|
||||
* and a new rolling stock has been attributed, display the new rolling stock's image and specifications.
|
||||
*/
|
||||
export function resolveEffectiveMateriel(
|
||||
compositionTrain: any[] | null | undefined,
|
||||
allMateriels: MaterielRoulantRow[] = [],
|
||||
fallbackData?: {
|
||||
modeleTrain?: string;
|
||||
capaciteTexte?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
): EffectiveMaterielResult {
|
||||
const comp = Array.isArray(compositionTrain) ? compositionTrain : [];
|
||||
|
||||
// Map to easily look up latest DB details by id
|
||||
const matMap = new Map<string, MaterielRoulantRow>();
|
||||
for (const m of allMateriels) {
|
||||
if (m && m.id != null) {
|
||||
matMap.set(String(m.id), m);
|
||||
}
|
||||
}
|
||||
|
||||
// If composition is empty, return fallback
|
||||
if (comp.length === 0) {
|
||||
return {
|
||||
nom: fallbackData?.modeleTrain || 'Train',
|
||||
imageUrl: fallbackData?.imageUrl,
|
||||
places: 163,
|
||||
voitures: 3,
|
||||
isReassigned: false,
|
||||
};
|
||||
}
|
||||
|
||||
const firstEntry = comp[0];
|
||||
const firstDbMat = firstEntry?.materiel_id ? matMap.get(String(firstEntry.materiel_id)) : undefined;
|
||||
const isFirstUnavailable = firstDbMat?.statut_maintenance && firstDbMat.statut_maintenance !== 'OPERATIONNEL';
|
||||
|
||||
// Check if a replacement is present in subsequent composition items:
|
||||
// e.g. when a replacement material was appended via roster management
|
||||
let replacementEntry = comp.slice(1).reverse().find(c => {
|
||||
if (!c) return false;
|
||||
const db = c.materiel_id ? matMap.get(String(c.materiel_id)) : undefined;
|
||||
return !db || db.statut_maintenance === 'OPERATIONNEL';
|
||||
});
|
||||
|
||||
const isReassigned = Boolean(isFirstUnavailable && replacementEntry);
|
||||
const targetEntry = isReassigned && replacementEntry ? replacementEntry : (
|
||||
// If not first unavailable but has multiple items, use the active one
|
||||
firstEntry
|
||||
);
|
||||
|
||||
const targetDbMat = targetEntry?.materiel_id ? matMap.get(String(targetEntry.materiel_id)) : undefined;
|
||||
|
||||
const imageUrl = targetDbMat?.image_url || targetEntry?.materiel_image_url || fallbackData?.imageUrl;
|
||||
const nom = targetDbMat?.nom || targetEntry?.materiel_nom || fallbackData?.modeleTrain || 'Train';
|
||||
const nomTechnique = targetDbMat?.nom_technique || targetEntry?.materiel_nom_technique;
|
||||
const places = targetDbMat?.capacite || targetEntry?.capacite || targetEntry?.materiel_nb_places || 163;
|
||||
const voitures = targetEntry?.materiel_voitures || (places > 200 ? 4 : 3);
|
||||
|
||||
return {
|
||||
materielId: targetEntry?.materiel_id,
|
||||
nom,
|
||||
nomTechnique,
|
||||
imageUrl,
|
||||
places,
|
||||
voitures,
|
||||
isReassigned,
|
||||
statutMaintenance: targetDbMat?.statut_maintenance,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { formatPerturbationCause } from './perturbationUtils';
|
||||
|
||||
export interface PerturbedStop {
|
||||
gare_id?: string;
|
||||
gare_nom: string;
|
||||
heure_depart?: string;
|
||||
heure_arrivee?: string;
|
||||
quai?: string;
|
||||
quai_reel?: string;
|
||||
voie?: string;
|
||||
is_depart?: boolean;
|
||||
is_arrivee?: boolean;
|
||||
is_nouveau_terminus?: boolean;
|
||||
is_nouvelle_origine?: boolean;
|
||||
is_added_manually?: boolean;
|
||||
is_supprime?: boolean;
|
||||
retard_minutes?: number;
|
||||
retard_cumule?: number;
|
||||
cause_arret?: string;
|
||||
types_perturbation?: string[];
|
||||
type_perturbation?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedParcours {
|
||||
stops: PerturbedStop[];
|
||||
destination: string;
|
||||
provenance: string;
|
||||
hasParcoursModification: boolean;
|
||||
nouveauTerminusNom?: string;
|
||||
nouvelleOrigineNom?: string;
|
||||
sillonSubstitutionId?: string;
|
||||
matchingPert?: any;
|
||||
}
|
||||
|
||||
const normName = (s: string) => (s || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||
|
||||
/**
|
||||
* Resolves the real-world route of a train taking into account any active perturbation:
|
||||
* - nouveau terminus: route terminates at new terminus, remaining stops marked non desservis
|
||||
* - nouvelle origine: route starts at new origin, preceding stops marked non desservis
|
||||
* - arrêts ajoutés manuellement: included in stop sequence
|
||||
* - arrêts supprimés: marked is_supprime
|
||||
* - stop-specific delays: propagated / updated
|
||||
*/
|
||||
export function resolvePerturbedRoute(
|
||||
horaire: any,
|
||||
matchingPert: any,
|
||||
gMap: Record<string, string> = {}
|
||||
): ResolvedParcours {
|
||||
// 1. Get original stops from horaire
|
||||
let originalGares: any[] = [];
|
||||
if (typeof horaire?.gares_desservies === 'string') {
|
||||
try { originalGares = JSON.parse(horaire.gares_desservies); } catch (e) { originalGares = []; }
|
||||
} else if (Array.isArray(horaire?.gares_desservies)) {
|
||||
originalGares = horaire.gares_desservies;
|
||||
}
|
||||
|
||||
const defaultDest = horaire?.gare_arrivee_nom || originalGares[originalGares.length - 1]?.gare_nom || gMap[horaire?.gare_arrivee_id] || 'Terminus';
|
||||
const defaultProv = horaire?.gare_depart_nom || originalGares[0]?.gare_nom || gMap[horaire?.gare_depart_id] || 'Origine';
|
||||
|
||||
if (!matchingPert) {
|
||||
const stops: PerturbedStop[] = originalGares.map((g, idx) => ({
|
||||
gare_id: g.gare_id,
|
||||
gare_nom: g.gare_nom || gMap[g.gare_id] || '',
|
||||
heure_depart: (g.heure_depart || g.heure_arrivee || '').slice(0, 5),
|
||||
heure_arrivee: (g.heure_arrivee || g.heure_depart || '').slice(0, 5),
|
||||
quai: g.quai || g.quai_reel,
|
||||
quai_reel: g.quai_reel,
|
||||
is_depart: idx === 0,
|
||||
is_arrivee: idx === originalGares.length - 1,
|
||||
}));
|
||||
return {
|
||||
stops,
|
||||
destination: defaultDest,
|
||||
provenance: defaultProv,
|
||||
hasParcoursModification: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check if matchingPert has perturbations_arrets
|
||||
let pertArretsRaw: any[] = Array.isArray(matchingPert.perturbations_arrets)
|
||||
? matchingPert.perturbations_arrets
|
||||
: (Array.isArray(matchingPert.perturbations_arrets?.arrets) ? matchingPert.perturbations_arrets.arrets : []);
|
||||
|
||||
const defaultPertCause = formatPerturbationCause(matchingPert?.cause) || matchingPert?.cause_details || 'Incident technique';
|
||||
|
||||
// Support root-level nouveau_terminus / nouvelle_origine if perturbations_arrets is empty
|
||||
if (pertArretsRaw.length === 0) {
|
||||
const rootNewTerm = matchingPert.nouveau_terminus || matchingPert.gare_terminus_nom;
|
||||
const rootNewOrig = matchingPert.nouvelle_origine || matchingPert.gare_origine_nom;
|
||||
if (rootNewTerm || rootNewOrig) {
|
||||
pertArretsRaw = originalGares.map(g => {
|
||||
const gNom = g.gare_nom || gMap[g.gare_id] || '';
|
||||
return {
|
||||
...g,
|
||||
gare_nom: gNom,
|
||||
is_nouveau_terminus: rootNewTerm && normName(gNom) === normName(rootNewTerm),
|
||||
is_nouvelle_origine: rootNewOrig && normName(gNom) === normName(rootNewOrig),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (pertArretsRaw.length === 0) {
|
||||
const isSuppGlobal = matchingPert.type_perturbation === 'suppression';
|
||||
const globalDelay = matchingPert.duree_retard_minutes || 0;
|
||||
const isModifGlobal = matchingPert.type_perturbation === 'modification_parcours';
|
||||
|
||||
const stops: PerturbedStop[] = originalGares.map((g, idx) => ({
|
||||
gare_id: g.gare_id,
|
||||
gare_nom: g.gare_nom || gMap[g.gare_id] || '',
|
||||
heure_depart: (g.heure_depart || g.heure_arrivee || '').slice(0, 5),
|
||||
heure_arrivee: (g.heure_arrivee || g.heure_depart || '').slice(0, 5),
|
||||
quai: g.quai || g.quai_reel,
|
||||
quai_reel: g.quai_reel,
|
||||
is_depart: idx === 0,
|
||||
is_arrivee: idx === originalGares.length - 1,
|
||||
is_supprime: isSuppGlobal,
|
||||
cause_arret: isSuppGlobal ? defaultPertCause : undefined,
|
||||
retard_minutes: globalDelay > 0 ? globalDelay : undefined,
|
||||
retard_cumule: globalDelay > 0 ? globalDelay : undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
stops,
|
||||
destination: defaultDest,
|
||||
provenance: defaultProv,
|
||||
hasParcoursModification: isModifGlobal,
|
||||
sillonSubstitutionId: matchingPert.sillon_substitution_id || undefined,
|
||||
matchingPert,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Process perturbed route from pertArretsRaw
|
||||
let effectiveArrets = [...pertArretsRaw];
|
||||
|
||||
// Check for nouvelle origine
|
||||
const origIdx = effectiveArrets.findIndex(a => a.is_nouvelle_origine);
|
||||
let nouvelleOrigineNom: string | undefined = undefined;
|
||||
let skippedLeadingStops: any[] = [];
|
||||
|
||||
if (origIdx !== -1) {
|
||||
nouvelleOrigineNom = effectiveArrets[origIdx].gare_nom;
|
||||
skippedLeadingStops = effectiveArrets.slice(0, origIdx);
|
||||
effectiveArrets = effectiveArrets.slice(origIdx);
|
||||
}
|
||||
|
||||
// Check for nouveau terminus
|
||||
const termIdx = effectiveArrets.findIndex(a => a.is_nouveau_terminus);
|
||||
let nouveauTerminusNom: string | undefined = undefined;
|
||||
let skippedTrailingStops: any[] = [];
|
||||
|
||||
if (termIdx !== -1) {
|
||||
nouveauTerminusNom = effectiveArrets[termIdx].gare_nom;
|
||||
skippedTrailingStops = effectiveArrets.slice(termIdx + 1);
|
||||
effectiveArrets = effectiveArrets.slice(0, termIdx + 1);
|
||||
}
|
||||
|
||||
// If pertArretsRaw was truncated in BDD and didn't include the trailing stops from originalGares:
|
||||
if (nouveauTerminusNom && skippedTrailingStops.length === 0 && originalGares.length > 0) {
|
||||
const normTerm = normName(nouveauTerminusNom);
|
||||
const origTermIdx = originalGares.findIndex(g => normName(g.gare_nom || gMap[g.gare_id] || '') === normTerm);
|
||||
if (origTermIdx !== -1 && origTermIdx < originalGares.length - 1) {
|
||||
skippedTrailingStops = originalGares.slice(origTermIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If pertArretsRaw didn't include the leading stops before nouvelleOrigineNom:
|
||||
if (nouvelleOrigineNom && skippedLeadingStops.length === 0 && originalGares.length > 0) {
|
||||
const normOrig = normName(nouvelleOrigineNom);
|
||||
const origOrigIdx = originalGares.findIndex(g => normName(g.gare_nom || gMap[g.gare_id] || '') === normOrig);
|
||||
if (origOrigIdx > 0) {
|
||||
skippedLeadingStops = originalGares.slice(0, origOrigIdx);
|
||||
}
|
||||
}
|
||||
|
||||
const hasModif = Boolean(
|
||||
nouveauTerminusNom ||
|
||||
nouvelleOrigineNom ||
|
||||
effectiveArrets.some(a => (a.types_perturbation || []).includes('modification_parcours') || a.is_added_manually) ||
|
||||
matchingPert.type_perturbation === 'modification_parcours'
|
||||
);
|
||||
|
||||
const destination = nouveauTerminusNom || effectiveArrets[effectiveArrets.length - 1]?.gare_nom || defaultDest;
|
||||
const provenance = nouvelleOrigineNom || effectiveArrets[0]?.gare_nom || defaultProv;
|
||||
|
||||
// Build active stops list
|
||||
const activeStops: PerturbedStop[] = effectiveArrets.map((a, idx) => {
|
||||
const sDep = (a.heure_depart || a.heure_arrivee || '').slice(0, 5);
|
||||
const sArr = (a.heure_arrivee || a.heure_depart || '').slice(0, 5);
|
||||
const types = a.types_perturbation || (a.type_perturbation ? [a.type_perturbation] : []);
|
||||
const isSupp = Boolean(
|
||||
matchingPert.type_perturbation === 'suppression' ||
|
||||
types.includes('arret_supprime') ||
|
||||
a.is_supprime ||
|
||||
a.supprime
|
||||
);
|
||||
const stopDelay = a.retard_cumule ?? a.retard_entrant ?? a.retard_minutes ?? (matchingPert.duree_retard_minutes || undefined);
|
||||
|
||||
return {
|
||||
gare_id: a.gare_id,
|
||||
gare_nom: a.gare_nom || gMap[a.gare_id] || '',
|
||||
heure_depart: sDep,
|
||||
heure_arrivee: sArr,
|
||||
quai: a.quai || a.quai_reel,
|
||||
quai_reel: a.quai_reel,
|
||||
is_depart: idx === 0,
|
||||
is_arrivee: idx === effectiveArrets.length - 1,
|
||||
is_nouveau_terminus: Boolean(a.is_nouveau_terminus),
|
||||
is_nouvelle_origine: Boolean(a.is_nouvelle_origine),
|
||||
is_added_manually: Boolean(a.is_added_manually),
|
||||
is_supprime: isSupp,
|
||||
retard_minutes: typeof stopDelay === 'number' && stopDelay > 0 ? stopDelay : undefined,
|
||||
retard_cumule: typeof a.retard_cumule === 'number' ? a.retard_cumule : undefined,
|
||||
cause_arret: formatPerturbationCause(a.cause_arret) || (isSupp ? defaultPertCause : undefined),
|
||||
types_perturbation: types,
|
||||
type_perturbation: a.type_perturbation || types[0],
|
||||
};
|
||||
});
|
||||
|
||||
// Leading stops skipped due to nouvelle origine (marked as suppressed)
|
||||
const suppressedLeading: PerturbedStop[] = skippedLeadingStops.map(g => ({
|
||||
gare_id: g.gare_id,
|
||||
gare_nom: g.gare_nom || gMap[g.gare_id] || '',
|
||||
heure_depart: (g.heure_depart || g.heure_arrivee || '').slice(0, 5),
|
||||
heure_arrivee: (g.heure_arrivee || g.heure_depart || '').slice(0, 5),
|
||||
quai: g.quai || g.quai_reel,
|
||||
quai_reel: g.quai_reel,
|
||||
is_depart: false,
|
||||
is_arrivee: false,
|
||||
is_supprime: true,
|
||||
cause_arret: formatPerturbationCause(g.cause_arret) || defaultPertCause,
|
||||
types_perturbation: ['arret_supprime', 'modification_parcours'],
|
||||
type_perturbation: 'arret_supprime',
|
||||
}));
|
||||
|
||||
// Trailing stops skipped due to nouveau terminus (marked as suppressed)
|
||||
const suppressedTrailing: PerturbedStop[] = skippedTrailingStops.map(g => ({
|
||||
gare_id: g.gare_id,
|
||||
gare_nom: g.gare_nom || gMap[g.gare_id] || '',
|
||||
heure_depart: (g.heure_depart || g.heure_arrivee || '').slice(0, 5),
|
||||
heure_arrivee: (g.heure_arrivee || g.heure_depart || '').slice(0, 5),
|
||||
quai: g.quai || g.quai_reel,
|
||||
quai_reel: g.quai_reel,
|
||||
is_depart: false,
|
||||
is_arrivee: false,
|
||||
is_supprime: true,
|
||||
cause_arret: formatPerturbationCause(g.cause_arret) || defaultPertCause,
|
||||
types_perturbation: ['arret_supprime', 'modification_parcours'],
|
||||
type_perturbation: 'arret_supprime',
|
||||
}));
|
||||
|
||||
const allResolvedStops: PerturbedStop[] = [
|
||||
...suppressedLeading,
|
||||
...activeStops,
|
||||
...suppressedTrailing,
|
||||
];
|
||||
|
||||
return {
|
||||
stops: allResolvedStops,
|
||||
destination,
|
||||
provenance,
|
||||
hasParcoursModification: hasModif,
|
||||
nouveauTerminusNom,
|
||||
nouvelleOrigineNom,
|
||||
sillonSubstitutionId: matchingPert.sillon_substitution_id || undefined,
|
||||
matchingPert,
|
||||
};
|
||||
}
|
||||
+137
-19
@@ -15,15 +15,101 @@ export interface PerturbationData {
|
||||
actif?: boolean;
|
||||
}
|
||||
|
||||
export type PerturbationPriorityType = 'suppression' | 'parcours_modifie' | 'retard' | 'a_l_heure';
|
||||
|
||||
/**
|
||||
* Resolves perturbation priority strictly conforming to user rule:
|
||||
* Supprimé > Modification de parcours > Retardé > A l'heure
|
||||
*/
|
||||
export function getPerturbationPriority(data: {
|
||||
isSupprime?: boolean;
|
||||
hasParcoursModification?: boolean;
|
||||
perturbationType?: string;
|
||||
typesPerturbation?: string[];
|
||||
delayMinutes?: number;
|
||||
}): PerturbationPriorityType {
|
||||
const types = (data.typesPerturbation || []).map(t => (t || '').toLowerCase());
|
||||
const pType = (data.perturbationType || '').toLowerCase();
|
||||
|
||||
// 1. SUPPRIMÉ
|
||||
if (
|
||||
data.isSupprime ||
|
||||
pType === 'suppression' ||
|
||||
pType.includes('suppr') ||
|
||||
types.includes('arret_supprime') ||
|
||||
types.includes('suppression')
|
||||
) {
|
||||
return 'suppression';
|
||||
}
|
||||
|
||||
// 2. MODIFICATION DE PARCOURS
|
||||
if (
|
||||
data.hasParcoursModification ||
|
||||
pType === 'modification_parcours' ||
|
||||
pType === 'parcours_modifie' ||
|
||||
pType.includes('parcours') ||
|
||||
pType.includes('modifi') ||
|
||||
types.includes('modification_parcours') ||
|
||||
types.includes('parcours_modifie')
|
||||
) {
|
||||
return 'parcours_modifie';
|
||||
}
|
||||
|
||||
// 3. RETARDÉ
|
||||
if (
|
||||
(typeof data.delayMinutes === 'number' && data.delayMinutes > 0) ||
|
||||
pType.includes('retard') ||
|
||||
types.includes('retard') ||
|
||||
types.includes('arret_prolonge')
|
||||
) {
|
||||
return 'retard';
|
||||
}
|
||||
|
||||
// 4. A L'HEURE
|
||||
return 'a_l_heure';
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a perturbation cause into a clear, concise passenger label
|
||||
*/
|
||||
export function formatPerturbationCause(cause?: string): string {
|
||||
if (!cause) return '';
|
||||
const c = cause.trim();
|
||||
const lower = c.toLowerCase();
|
||||
if (lower.includes('passage_niveau') || lower.includes('passage a niveau') || lower.includes('passage à niveau')) return 'Accident à un passage à niveau';
|
||||
if (lower.includes('accident_personne') || lower.includes('personne')) return 'Accident de personne';
|
||||
if (lower.includes('deraillement')) return 'Déraillement de train';
|
||||
if (lower === 'incident_technique') return 'Incident technique';
|
||||
if (lower === 'panne_materiel' || lower === 'panne_de_train' || lower.includes('panne_de_train')) return 'Panne de train';
|
||||
if (lower === 'conditions_meteo' || lower === 'meteo') return 'Conditions météo';
|
||||
if (lower === 'obstacle_voie') return 'Obstacle sur la voie';
|
||||
if (lower === 'travaux') return 'Travaux sur la voie';
|
||||
if (lower === 'greve') return 'Mouvement social';
|
||||
if (lower === 'panne_signalisation') return 'Panne de signalisation';
|
||||
if (c.includes('_')) {
|
||||
const formatted = c.replace(/_/g, ' ');
|
||||
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
|
||||
}
|
||||
return c.charAt(0).toUpperCase() + c.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a perturbation strictly applies to the recorded target ISO date (YYYY-MM-DD)
|
||||
*/
|
||||
export function isPerturbationActiveOnDate(pert: any, targetDateIso: string): boolean {
|
||||
if (!pert || pert.actif === false) return false;
|
||||
|
||||
// 1. Check jours_perturbation array if present
|
||||
if (Array.isArray(pert.jours_perturbation) && pert.jours_perturbation.length > 0) {
|
||||
return pert.jours_perturbation.some((jp: any) => {
|
||||
// 1. Check jours_perturbation (from pert, pert.perturbations_arrets, or pert.details)
|
||||
const joursArr = (Array.isArray(pert.jours_perturbation) && pert.jours_perturbation.length > 0)
|
||||
? pert.jours_perturbation
|
||||
: (Array.isArray(pert.perturbations_arrets?.jours_perturbation) && pert.perturbations_arrets.jours_perturbation.length > 0)
|
||||
? pert.perturbations_arrets.jours_perturbation
|
||||
: (Array.isArray(pert.details?.jours_perturbation) && pert.details.jours_perturbation.length > 0)
|
||||
? pert.details.jours_perturbation
|
||||
: null;
|
||||
|
||||
if (joursArr) {
|
||||
const match = joursArr.some((jp: any) => {
|
||||
if (typeof jp === 'string') {
|
||||
return jp.slice(0, 10) === targetDateIso;
|
||||
}
|
||||
@@ -33,11 +119,28 @@ export function isPerturbationActiveOnDate(pert: any, targetDateIso: string): bo
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (match) return true;
|
||||
}
|
||||
|
||||
// 2. Check date_debut and date_fin
|
||||
const start = pert.date_debut ? pert.date_debut.slice(0, 10) : null;
|
||||
const end = pert.date_fin ? pert.date_fin.slice(0, 10) : null;
|
||||
const extractDate = (val: any): string | null => {
|
||||
if (!val) return null;
|
||||
if (typeof val === 'string') {
|
||||
const match = val.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
const d = new Date(val);
|
||||
if (!isNaN(d.getTime())) {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const start = extractDate(pert.date_debut);
|
||||
const end = extractDate(pert.date_fin);
|
||||
|
||||
if (start && end) {
|
||||
return targetDateIso >= start && targetDateIso <= end;
|
||||
@@ -57,28 +160,18 @@ export interface BannerStyle {
|
||||
bgColor: string; // Background color matching Image 2
|
||||
textColor: string; // Text color
|
||||
iconBgColor: string; // Icon circle bg color
|
||||
iconText: string; // Icon character ('i', '!', '✕', '↔')
|
||||
badgeLabel: string; // Label ('Information', 'Informations', 'Retard', 'Suppression', 'Parcours modifié')
|
||||
iconText: string; // Icon character ('i', '!', '✕', '↔', '✓')
|
||||
badgeLabel: string; // Label ('Information', 'Informations', 'Retard', 'Suppression', 'Parcours modifié', 'A l'heure')
|
||||
defaultCause?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets banner styling and metadata strictly matching Image 2
|
||||
* Gets banner styling and metadata strictly matching Image 2 & user priority
|
||||
*/
|
||||
export function getPerturbationBannerStyle(type: string): BannerStyle {
|
||||
const normType = (type || '').toLowerCase();
|
||||
|
||||
if (normType.includes('retard')) {
|
||||
return {
|
||||
bgColor: '#fff1ed',
|
||||
textColor: '#c00',
|
||||
iconBgColor: '#e65100',
|
||||
iconText: '!',
|
||||
badgeLabel: 'Retard',
|
||||
defaultCause: 'Retard estimé'
|
||||
};
|
||||
}
|
||||
|
||||
// 1. SUPPRIMÉ
|
||||
if (normType.includes('suppr')) {
|
||||
return {
|
||||
bgColor: '#fdeded',
|
||||
@@ -90,6 +183,7 @@ export function getPerturbationBannerStyle(type: string): BannerStyle {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. MODIFICATION DE PARCOURS
|
||||
if (normType.includes('parcours') || normType.includes('modifi')) {
|
||||
return {
|
||||
bgColor: '#f0f7ff',
|
||||
@@ -101,6 +195,30 @@ export function getPerturbationBannerStyle(type: string): BannerStyle {
|
||||
};
|
||||
}
|
||||
|
||||
// 3. RETARDÉ
|
||||
if (normType.includes('retard')) {
|
||||
return {
|
||||
bgColor: '#fff1ed',
|
||||
textColor: '#c00',
|
||||
iconBgColor: '#e65100',
|
||||
iconText: '!',
|
||||
badgeLabel: 'Retard',
|
||||
defaultCause: 'Retard estimé'
|
||||
};
|
||||
}
|
||||
|
||||
// 4. A L'HEURE
|
||||
if (normType.includes('heure') || normType === 'a_l_heure' || normType === 'normal') {
|
||||
return {
|
||||
bgColor: '#f0fdf4',
|
||||
textColor: '#15803d',
|
||||
iconBgColor: '#15803d',
|
||||
iconText: '✓',
|
||||
badgeLabel: 'A l\'heure',
|
||||
defaultCause: 'Train à l\'heure'
|
||||
};
|
||||
}
|
||||
|
||||
// Default: Information
|
||||
return {
|
||||
bgColor: '#eef7fc',
|
||||
|
||||
Reference in new issue
Block a user