UPDATE PROCHAINS D2PARTS

This commit is contained in:
AdminGit committed 2026-10-04 00:32:16 +02:00
1 parent d54ba7163a
commit 710b5a3a6d
12 files changed
+1777 -500

No files matched your search

+4 -4
View File
@@ -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
View File
@@ -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]">
@@ -63,6 +82,42 @@ export default function PortalPage() {
))}
</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">
<p className="text-[13px] text-gray-600 flex items-center gap-2">
+106 -52
View File
@@ -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>
+321 -97
View File
@@ -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>
);
+2 -2
View File
@@ -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) {
-11
View File
@@ -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>
+333 -125
View File
@@ -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&apos;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>
))
) : (
+93 -17
View File
@@ -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>
+361 -172
View File
@@ -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>
+95
View File
@@ -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,
};
}
+269
View File
@@ -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
View File
@@ -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',