RELEASE V.4.0.0 : VERSION LONG SUPPORT (LTS)

--> Imports de ligne via fichier GTFS
--> Extrapolation possible des données
--> Générateur de fiches horaires
This commit is contained in:
mathis committed 2026-09-27 17:06:21 +02:00
1 parent 1c68740e41
commit a7f0c96435
7 files changed
+1085 -183

No files matched your search

+3 -1
View File
@@ -1,2 +1,4 @@
REACT_APP_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr
REACT_APP_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzg3NjY3MjMxLCJleHAiOjE5NDUzNDcyMzF9.uD7YUlOFIhK_Jqbra94o5-N5zdrzKezoCxV067RJi78
REACT_APP_SUPABASE_ANON_KEY=sb_publishable_jSS9wIDj3BWGBpxtv5LwGQ_1P_KBV_N
NEXT_PUBLIC_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_jSS9wIDj3BWGBpxtv5LwGQ_1P_KBV_N
+3 -1
View File
@@ -1,2 +1,4 @@
REACT_APP_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr
REACT_APP_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzg3NjY3MjMxLCJleHAiOjE5NDUzNDcyMzF9.uD7YUlOFIhK_Jqbra94o5-N5zdrzKezoCxV067RJi78
REACT_APP_SUPABASE_ANON_KEY=sb_publishable_jSS9wIDj3BWGBpxtv5LwGQ_1P_KBV_N
NEXT_PUBLIC_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_jSS9wIDj3BWGBpxtv5LwGQ_1P_KBV_N
+60 -9
View File
@@ -1022,16 +1022,67 @@ export function formatHeure(time) {
*/
export function getJoursCirculation(horaire) {
if (!horaire) return [];
const parseBool = (v) => {
if (typeof v === 'boolean') return v;
if (typeof v === 'number') return v === 1;
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
return s === 'true' || s === 't' || s === '1' || s === 'oui' || s === 'yes' || s === 'y';
}
return false;
};
const jours = [];
if (horaire.circule_lundi) jours.push('Lun');
if (horaire.circule_mardi) jours.push('Mar');
if (horaire.circule_mercredi) jours.push('Mer');
if (horaire.circule_jeudi) jours.push('Jeu');
if (horaire.circule_vendredi) jours.push('Ven');
if (horaire.circule_samedi) jours.push('Sam');
if (horaire.circule_dimanche) jours.push('Dim');
return jours;
}export async function updateQuaisReelsHoraire(horaireId, quaisParOrdre, isPropagation = false) {
if (parseBool(horaire.circule_lundi)) jours.push('Lun');
if (parseBool(horaire.circule_mardi)) jours.push('Mar');
if (parseBool(horaire.circule_mercredi)) jours.push('Mer');
if (parseBool(horaire.circule_jeudi)) jours.push('Jeu');
if (parseBool(horaire.circule_vendredi)) jours.push('Ven');
if (parseBool(horaire.circule_samedi)) jours.push('Sam');
if (parseBool(horaire.circule_dimanche)) jours.push('Dim');
if (jours.length > 0) return jours;
// Fallback si formaté en chaîne de caractères
const txt = String(horaire.jours_circulation || horaire.frequence || horaire.periodicite || '').toLowerCase();
if (txt) {
if (txt.includes('tous') || txt.includes('quotidien') || txt.includes('7j')) {
return ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
}
if (txt.includes('lun') && txt.includes('ven')) {
return ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven'];
}
if (txt.includes('lun') && txt.includes('sam')) {
return ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'];
}
if (txt.includes('sam') && txt.includes('dim')) {
return ['Sam', 'Dim'];
}
if (txt.includes('dim')) return ['Dim'];
if (txt.includes('sam')) return ['Sam'];
if (txt.includes('ven')) return ['Ven'];
}
// Fallback jours personnalisés
if (Array.isArray(horaire.jours_personnalises) && horaire.jours_personnalises.length > 0) {
const dows = new Set();
const mapDow = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'];
horaire.jours_personnalises.forEach(j => {
if (j && j.circule !== false && j.date) {
const d = new Date(j.date).getDay();
if (!isNaN(d)) dows.add(mapDow[d]);
}
});
if (dows.size > 0) {
return ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].filter(d => dows.has(d));
}
}
return [];
}
export async function updateQuaisReelsHoraire(horaireId, quaisParOrdre, isPropagation = false) {
if (!isSupabaseConfigured) {
return { data: null, error: null };
}
+65 -10
View File
@@ -620,6 +620,7 @@ export default function FichesHoraires() {
return;
}
const defaultJours = manualTrainForm.jours_circulation || 'Lun à Ven*';
const newTrain = {
id: `manual_${Date.now()}`,
numero_train: manualTrainForm.numero_train || `T${Math.floor(1000 + Math.random() * 9000)}`,
@@ -629,7 +630,8 @@ export default function FichesHoraires() {
gare_arrivee_nom: manualTrainForm.gare_arrivee_nom || orderedGaresForLine[orderedGaresForLine.length - 1]?.nom || 'Gare d\'arrivée',
heure_depart: manualTrainForm.heure_depart || '08:00',
heure_arrivee: manualTrainForm.heure_arrivee || '09:00',
jours_circulation: manualTrainForm.jours_circulation || 'Lun à Ven',
jours_circulation: defaultJours,
_custom_jours: defaultJours,
est_substitution: !!manualTrainForm.est_substitution,
gares_desservies: []
};
@@ -644,12 +646,25 @@ export default function FichesHoraires() {
heure_depart: '08:00',
gare_arrivee_nom: '',
heure_arrivee: '09:00',
jours_circulation: 'Lun à Ven',
jours_circulation: 'Lun à Ven*',
est_substitution: false
});
showToast(`Circulation ${newTrain.numero_train} ajoutée avec succès !`);
};
const handleUpdateTrainJours = (trainId, newJours) => {
setWizardOrderedTrains(prev => prev.map(t => {
if (t.id === trainId) {
return {
...t,
_custom_jours: newJours,
jours_circulation: newJours
};
}
return t;
}));
};
// Helper to get direction of a circulation
const getCirculationDirection = (circ, orderedGares = []) => {
if (!circ) return 'aller';
@@ -1992,9 +2007,36 @@ export default function FichesHoraires() {
{train.heure_depart || '—'} → {train.heure_arrivee || '—'}
</td>
<td>
<span style={{ fontSize: '0.8rem', color: '#64748b' }}>
{formatCirculationDaysCompact(train)}
</span>
<select
value={train._custom_jours || formatCirculationDaysCompact(train)}
onChange={(e) => handleUpdateTrainJours(train.id, e.target.value)}
style={{
fontSize: '0.78rem',
padding: '3px 6px',
borderRadius: 6,
border: '1px solid #cbd5e1',
background: '#ffffff',
color: '#1e293b',
cursor: 'pointer',
maxWidth: 135
}}
title="Modifier les jours de circulation pour cette fiche horaire"
>
<option value="Lun à Ven*">Lun à Ven* (Sauf fêtes)</option>
<option value="Tous les jours">Tous les jours</option>
<option value="Lun à Sam*">Lun à Sam* (Sauf fêtes)</option>
<option value="Dim et Fêtes">Dim et Fêtes</option>
<option value="Ven, Dim et Fêtes">Ven, Dim et Fêtes</option>
<option value="Sam et Dim">Sam et Dim</option>
<option value="Sauf Sam">Sauf Sam</option>
<option value="Lun à Ven">Lun à Ven (Avec fêtes)</option>
<option value="Lun à Sam">Lun à Sam (Avec fêtes)</option>
<option value="Sam, Dim et Fêtes">Sam, Dim et Fêtes</option>
<option value="Sam">Samedi</option>
<option value="Dim">Dimanche</option>
<option value="Ven">Vendredi</option>
<option value="Lun à Jeu">Lun à Jeu</option>
</select>
</td>
<td>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end', alignItems: 'center' }}>
@@ -2281,13 +2323,26 @@ export default function FichesHoraires() {
<div className="fh-grid-2">
<div className="fh-form-group">
<label>Jours de circulation :</label>
<input
type="text"
className="fh-form-input"
placeholder="Ex: Lun à Ven"
<select
className="fh-select"
value={manualTrainForm.jours_circulation}
onChange={(e) => setManualTrainForm(prev => ({ ...prev, jours_circulation: e.target.value }))}
/>
>
<option value="Lun à Ven*">Lun à Ven* (Lundi à Vendredi sauf fêtes)</option>
<option value="Tous les jours">Tous les jours (Quotidien)</option>
<option value="Lun à Sam*">Lun à Sam* (Lundi à Samedi sauf fêtes)</option>
<option value="Dim et Fêtes">Dim et Fêtes</option>
<option value="Ven, Dim et Fêtes">Ven, Dim et Fêtes</option>
<option value="Sam et Dim">Sam et Dim (Week-end)</option>
<option value="Sauf Sam">Sauf Sam (Dimanche au Vendredi)</option>
<option value="Lun à Ven">Lun à Ven (Circule fêtes)</option>
<option value="Lun à Sam">Lun à Sam (Circule fêtes)</option>
<option value="Sam, Dim et Fêtes">Sam, Dim et Fêtes</option>
<option value="Sam">Samedi</option>
<option value="Dim">Dimanche</option>
<option value="Ven">Vendredi</option>
<option value="Lun à Jeu">Lun à Jeu</option>
</select>
</div>
<div className="fh-form-group" style={{ display: 'flex', alignItems: 'center', paddingTop: 24 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
+297
View File
@@ -1715,5 +1715,302 @@ body.dark-mode .gtfs-day-pill.inactive,
color: #64748b;
}
/* GTFS View Toggle */
.gtfs-view-toggle {
display: flex;
background: #f1f5f9;
border-radius: 8px;
padding: 3px;
gap: 3px;
}
.gtfs-view-toggle-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
border: none;
background: transparent;
font-size: 0.85rem;
font-weight: 600;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
}
.gtfs-view-toggle-btn:hover {
color: #1e293b;
}
.gtfs-view-toggle-btn.active {
background: #ffffff;
color: #2563eb;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* GTFS Accordion Styles */
.gtfs-accordion-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.gtfs-accordion-item {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.gtfs-accordion-item:hover {
border-color: #cbd5e1;
}
.gtfs-accordion-item.has-selected {
border-color: #93c5fd;
}
.gtfs-accordion-item.open {
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
.gtfs-accordion-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 18px;
cursor: pointer;
user-select: none;
background: #ffffff;
transition: background 0.15s ease;
}
.gtfs-accordion-header:hover {
background: #f8fafc;
}
.gtfs-accordion-header-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.gtfs-accordion-route-name {
font-size: 0.95rem;
font-weight: 700;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gtfs-accordion-header-right {
display: flex;
align-items: center;
gap: 14px;
}
.gtfs-accordion-count-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
font-weight: 600;
color: #64748b;
background: #f1f5f9;
padding: 4px 10px;
border-radius: 20px;
}
.gtfs-accordion-count-badge.active {
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
.gtfs-accordion-expand-btn {
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
padding: 4px;
border-radius: 6px;
transition: background 0.2s;
}
.gtfs-accordion-expand-btn:hover {
background: #e2e8f0;
}
.gtfs-accordion-body {
border-top: 1px solid #e2e8f0;
background: #f8fafc;
padding: 12px 16px;
animation: fadeIn 0.2s ease-out;
}
.gtfs-accordion-body-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
flex-wrap: wrap;
gap: 8px;
}
.gtfs-accordion-table-wrapper {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: auto;
max-height: 420px;
}
.gtfs-accordion-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
text-align: left;
}
.gtfs-accordion-table th {
background: #f8fafc;
color: #475569;
font-weight: 600;
padding: 10px 12px;
border-bottom: 1px solid #e2e8f0;
position: sticky;
top: 0;
z-index: 5;
}
.gtfs-accordion-table td {
padding: 9px 12px;
border-bottom: 1px solid #f1f5f9;
color: #334155;
}
.gtfs-accordion-trip-row {
cursor: pointer;
transition: background 0.15s ease;
}
.gtfs-accordion-trip-row:hover {
background: #f8fafc;
}
.gtfs-accordion-trip-row.selected {
background: #eff6ff;
}
.gtfs-accordion-trip-row.selected:hover {
background: #e0e7ff;
}
/* Dark mode overrides for GTFS Accordion */
body.dark-mode .gtfs-view-toggle,
[data-theme="dark"] .gtfs-view-toggle {
background: #0f172a;
}
body.dark-mode .gtfs-view-toggle-btn,
[data-theme="dark"] .gtfs-view-toggle-btn {
color: #94a3b8;
}
body.dark-mode .gtfs-view-toggle-btn.active,
[data-theme="dark"] .gtfs-view-toggle-btn.active {
background: #1e293b;
color: #60a5fa;
}
body.dark-mode .gtfs-accordion-item,
[data-theme="dark"] .gtfs-accordion-item {
background: #1e293b;
border-color: #334155;
}
body.dark-mode .gtfs-accordion-item.has-selected,
[data-theme="dark"] .gtfs-accordion-item.has-selected {
border-color: #1d4ed8;
}
body.dark-mode .gtfs-accordion-header,
[data-theme="dark"] .gtfs-accordion-header {
background: #1e293b;
}
body.dark-mode .gtfs-accordion-header:hover,
[data-theme="dark"] .gtfs-accordion-header:hover {
background: #243044;
}
body.dark-mode .gtfs-accordion-route-name,
[data-theme="dark"] .gtfs-accordion-route-name {
color: #f1f5f9;
}
body.dark-mode .gtfs-accordion-count-badge,
[data-theme="dark"] .gtfs-accordion-count-badge {
background: #0f172a;
color: #94a3b8;
}
body.dark-mode .gtfs-accordion-count-badge.active,
[data-theme="dark"] .gtfs-accordion-count-badge.active {
background: #1e3a8a;
color: #93c5fd;
border-color: #3b82f6;
}
body.dark-mode .gtfs-accordion-body,
[data-theme="dark"] .gtfs-accordion-body {
background: #0f172a;
border-color: #334155;
}
body.dark-mode .gtfs-accordion-table-wrapper,
[data-theme="dark"] .gtfs-accordion-table-wrapper {
background: #1e293b;
border-color: #334155;
}
body.dark-mode .gtfs-accordion-table th,
[data-theme="dark"] .gtfs-accordion-table th {
background: #0f172a;
color: #cbd5e1;
border-color: #334155;
}
body.dark-mode .gtfs-accordion-table td,
[data-theme="dark"] .gtfs-accordion-table td {
border-color: #334155;
color: #cbd5e1;
}
body.dark-mode .gtfs-accordion-table td strong,
[data-theme="dark"] .gtfs-accordion-table td strong {
color: #f8fafc !important;
}
body.dark-mode .gtfs-accordion-trip-row:hover,
[data-theme="dark"] .gtfs-accordion-trip-row:hover {
background: #243044;
}
body.dark-mode .gtfs-accordion-trip-row.selected,
[data-theme="dark"] .gtfs-accordion-trip-row.selected {
background: #1e3a8a;
}
+470 -83
View File
@@ -1257,6 +1257,8 @@ export default function ImportsPage() {
const [gtfsDirectionFilter, setGtfsDirectionFilter] = useState('all');
const [gtfsTripSearch, setGtfsTripSearch] = useState('');
const [gtfsSelectedTripIds, setGtfsSelectedTripIds] = useState(new Set());
const [gtfsViewMode, setGtfsViewMode] = useState('accordion'); // 'accordion' | 'grid'
const [gtfsOpenAccordionRouteIds, setGtfsOpenAccordionRouteIds] = useState(new Set());
const [gtfsConverting, setGtfsConverting] = useState(false);
const [gtfsPage, setGtfsPage] = useState(1);
@@ -2420,10 +2422,74 @@ export default function ImportsPage() {
});
};
const handleToggleAccordionRoute = (routeId) => {
setGtfsOpenAccordionRouteIds(prev => {
const next = new Set(prev);
if (next.has(routeId)) next.delete(routeId);
else next.add(routeId);
return next;
});
};
const handleExpandAllAccordion = (routes) => {
setGtfsOpenAccordionRouteIds(new Set(routes.map(r => r.route_id)));
};
const handleCollapseAllAccordion = () => {
setGtfsOpenAccordionRouteIds(new Set());
};
const handleToggleRouteSelection = (routeId, select) => {
if (!gtfsParsedData) return;
const routeTrips = gtfsParsedData.tripsByRoute?.[routeId] || [];
setGtfsSelectedTripIds(prev => {
const next = new Set(prev);
routeTrips.forEach(t => {
if (select) next.add(t.trip_id);
else next.delete(t.trip_id);
});
return next;
});
};
const handleSelectAllRoutesAndTrips = (routes, select) => {
if (!gtfsParsedData) return;
setGtfsSelectedTripIds(prev => {
const next = new Set(prev);
routes.forEach(r => {
const routeTrips = gtfsParsedData.tripsByRoute?.[r.route_id] || [];
routeTrips.forEach(t => {
if (select) next.add(t.trip_id);
else next.delete(t.trip_id);
});
});
return next;
});
};
const handleConvertAndImportGTFSTrips = () => {
if (!gtfsParsedData || !gtfsSelectedRouteId) return;
const allRouteTrips = gtfsParsedData.tripsByRoute[gtfsSelectedRouteId] || [];
const selectedTrips = allRouteTrips.filter(t => gtfsSelectedTripIds.has(t.trip_id));
if (!gtfsParsedData) return;
let selectedTrips = [];
let titleName = 'Lignes GTFS';
if (gtfsSelectedRouteId && gtfsStep === 4) {
const allRouteTrips = gtfsParsedData.tripsByRoute[gtfsSelectedRouteId] || [];
selectedTrips = allRouteTrips.filter(t => gtfsSelectedTripIds.has(t.trip_id));
const route = gtfsParsedData.routesById[gtfsSelectedRouteId];
titleName = route ? (route.route_long_name || route.route_short_name || route.route_id) : 'Ligne GTFS';
} else {
const allTrips = gtfsParsedData.trips || [];
selectedTrips = allTrips.filter(t => gtfsSelectedTripIds.has(t.trip_id));
const distinctRouteIds = [...new Set(selectedTrips.map(t => t.route_id))];
if (distinctRouteIds.length === 1) {
const route = gtfsParsedData.routesById[distinctRouteIds[0]];
titleName = route ? (route.route_long_name || route.route_short_name || route.route_id) : 'Ligne GTFS';
} else {
titleName = `${distinctRouteIds.length} lignes (${selectedTrips.length} circulations)`;
}
}
if (selectedTrips.length === 0) {
alert('Veuillez sélectionner au moins une circulation à importer.');
return;
@@ -2431,9 +2497,6 @@ export default function ImportsPage() {
setGtfsConverting(true);
try {
const route = gtfsParsedData.routesById[gtfsSelectedRouteId];
const routeName = route ? (route.route_long_name || route.route_short_name || route.route_id) : 'Ligne GTFS';
const converted = convertGTFSTripsToHoraireRows(
selectedTrips,
gtfsParsedData,
@@ -2448,7 +2511,7 @@ export default function ImportsPage() {
const withDupes = annotateWithExisting(sanitizedRows);
const { updatedRows, missingWindows } = updateRowsWithServiceDetection(withDupes, servicesAnnuels);
setFileName(`GTFS - ${routeName}`);
setFileName(`GTFS - ${titleName}`);
setDetectedHeaders(SUPABASE_EXPORT_HEADERS);
setSelectedRows(new Set(
updatedRows.map((r, i) => ({ r, i })).filter(({ r }) => !r._duplicate).map(({ i }) => i)
@@ -3040,88 +3103,412 @@ export default function ImportsPage() {
</div>
)}
{/* GTFS Step 3: Choix de la ligne */}
{/* GTFS Step 3: Choix de la ligne / Multi-lignes Accordion */}
{gtfsStep === 3 && gtfsParsedData && !gtfsLoading && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 10 }}>
<h3 className="step-title" style={{ margin: 0 }}><wcs-mat-icon icon="alt_route" size="m" /> 3. Choix de la ligne à importer</h3>
<wcs-button mode="stroked" onClick={() => setGtfsStep(2)}>Retour au Visualisateur</wcs-button>
</div>
{(() => {
const filteredRoutes = (gtfsParsedData.routes || []).filter(r => {
if (gtfsRouteTypeFilter !== 'all' && String(r.route_type) !== gtfsRouteTypeFilter) return false;
if (gtfsRouteSearch) {
const q = gtfsRouteSearch.toLowerCase();
const matchRoute = (r.route_id || '').toLowerCase().includes(q) ||
(r.route_short_name || '').toLowerCase().includes(q) ||
(r.route_long_name || '').toLowerCase().includes(q);
if (matchRoute) return true;
// Search also inside trips of this route
const routeTrips = gtfsParsedData.tripsByRoute[r.route_id] || [];
return routeTrips.some(t => (t.trip_id || '').toLowerCase().includes(q) || (t.trip_headsign || '').toLowerCase().includes(q) || (t.trip_short_name || '').toLowerCase().includes(q));
}
return true;
});
<div className="gtfs-filter-bar">
<input
type="text"
className="norm-search-input"
style={{ maxWidth: 360 }}
placeholder="Rechercher par numéro de ligne, nom..."
value={gtfsRouteSearch}
onChange={(e) => setGtfsRouteSearch(e.target.value)}
/>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{[
{ key: 'all', label: 'Toutes les lignes' },
{ key: '2', label: '🚆 Trains' },
{ key: '3', label: '🚌 Substitutions (Bus/Car)' },
{ key: '0', label: '🚊 Tram' }
].map(f => (
<button
key={f.key}
type="button"
className={`norm-filter-chip ${gtfsRouteTypeFilter === f.key ? 'active' : ''}`}
onClick={() => setGtfsRouteTypeFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
const totalSelectedTripsCount = gtfsSelectedTripIds.size;
const selectedRoutesCount = new Set(
(gtfsParsedData.trips || [])
.filter(t => gtfsSelectedTripIds.has(t.trip_id))
.map(t => t.route_id)
).size;
<div className="gtfs-routes-grid">
{(gtfsParsedData.routes || [])
.filter(r => {
if (gtfsRouteTypeFilter !== 'all' && String(r.route_type) !== gtfsRouteTypeFilter) return false;
if (gtfsRouteSearch) {
const q = gtfsRouteSearch.toLowerCase();
return (r.route_id || '').toLowerCase().includes(q) ||
(r.route_short_name || '').toLowerCase().includes(q) ||
(r.route_long_name || '').toLowerCase().includes(q);
}
return true;
})
.map(r => {
const tripsCount = (gtfsParsedData.tripsByRoute[r.route_id] || []).length;
const bgColor = r.route_color ? `#${r.route_color}` : '#2563eb';
const textColor = r.route_text_color ? `#${r.route_text_color}` : '#ffffff';
const isBus = String(r.route_type) === '3' || (r.route_type >= 700 && r.route_type <= 716);
return (
<div
key={r.route_id}
className={`gtfs-route-card ${gtfsSelectedRouteId === r.route_id ? 'selected' : ''}`}
onClick={() => handleSelectGTFSRoute(r.route_id)}
>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="gtfs-route-badge" style={{ backgroundColor: bgColor, color: textColor }}>
{r.route_short_name || r.route_id}
</span>
<span className="gtfs-route-type-badge">
{isBus ? '🚌 Substitution' : '🚆 Train'}
</span>
</div>
<div className="gtfs-route-name">
{r.route_long_name || r.route_short_name || r.route_id}
</div>
</div>
<div style={{ marginTop: 14, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="gtfs-route-trips-count">
<wcs-mat-icon icon="schedule" size="s" /> {tripsCount} circulation(s)
</div>
<wcs-button mode="stroked" size="s">Sélectionner →</wcs-button>
const allFilteredTrips = filteredRoutes.flatMap(r => gtfsParsedData.tripsByRoute[r.route_id] || []);
const allFilteredSelected = allFilteredTrips.length > 0 && allFilteredTrips.every(t => gtfsSelectedTripIds.has(t.trip_id));
const allAccordionOpen = filteredRoutes.length > 0 && filteredRoutes.every(r => gtfsOpenAccordionRouteIds.has(r.route_id));
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 10 }}>
<div>
<h3 className="step-title" style={{ margin: 0 }}>
<wcs-mat-icon icon="alt_route" size="m" /> 3. Choix des lignes & circulations à importer
</h3>
<div style={{ fontSize: '0.85rem', color: '#64748b', marginTop: 3 }}>
Sélectionnez les lignes et cochez les horaires que vous souhaitez importer dans votre base.
</div>
</div>
);
})}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="gtfs-view-toggle">
<button
type="button"
className={`gtfs-view-toggle-btn ${gtfsViewMode === 'accordion' ? 'active' : ''}`}
onClick={() => setGtfsViewMode('accordion')}
>
<wcs-mat-icon icon="view_headline" size="s" /> Vue Accordéon Multi-lignes
</button>
<button
type="button"
className={`gtfs-view-toggle-btn ${gtfsViewMode === 'grid' ? 'active' : ''}`}
onClick={() => setGtfsViewMode('grid')}
>
<wcs-mat-icon icon="grid_view" size="s" /> Vue Grille
</button>
</div>
<wcs-button mode="stroked" onClick={() => setGtfsStep(2)}>Retour au Visualisateur</wcs-button>
</div>
</div>
{/* Filter & Action bar */}
<div className="gtfs-filter-bar" style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', flex: 1 }}>
<input
type="text"
className="norm-search-input"
style={{ minWidth: 260, maxWidth: 360 }}
placeholder="Rechercher par nom de ligne, numéro, arrêt..."
value={gtfsRouteSearch}
onChange={(e) => setGtfsRouteSearch(e.target.value)}
/>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{[
{ key: 'all', label: 'Toutes les lignes' },
{ key: '2', label: '🚆 Trains' },
{ key: '3', label: '🚌 Substitutions (Bus/Car)' },
{ key: '0', label: '🚊 Tram' }
].map(f => (
<button
key={f.key}
type="button"
className={`norm-filter-chip ${gtfsRouteTypeFilter === f.key ? 'active' : ''}`}
onClick={() => setGtfsRouteTypeFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<wcs-button
mode="stroked"
size="s"
onClick={() => handleSelectAllRoutesAndTrips(filteredRoutes, !allFilteredSelected)}
>
{allFilteredSelected ? 'Tout désélectionner' : 'Tout sélectionner'}
</wcs-button>
{gtfsViewMode === 'accordion' && (
<wcs-button
mode="stroked"
size="s"
onClick={() => allAccordionOpen ? handleCollapseAllAccordion() : handleExpandAllAccordion(filteredRoutes)}
>
{allAccordionOpen ? 'Replier tout' : 'Déplier tout'}
</wcs-button>
)}
</div>
</div>
{/* ACCORDION VIEW MODE */}
{gtfsViewMode === 'accordion' && (
<div className="gtfs-accordion-list">
{filteredRoutes.length === 0 ? (
<div className="empty-state" style={{ padding: 40, textAlign: 'center' }}>
<wcs-mat-icon icon="search_off" size="l" />
<p style={{ marginTop: 8, color: '#64748b' }}>Aucune ligne ne correspond à votre recherche.</p>
</div>
) : (
filteredRoutes.map(r => {
const routeTrips = gtfsParsedData.tripsByRoute[r.route_id] || [];
const selectedInRoute = routeTrips.filter(t => gtfsSelectedTripIds.has(t.trip_id)).length;
const isAllRouteSelected = routeTrips.length > 0 && selectedInRoute === routeTrips.length;
const isPartiallySelected = selectedInRoute > 0 && !isAllRouteSelected;
const isOpen = gtfsOpenAccordionRouteIds.has(r.route_id);
const bgColor = r.route_color ? `#${r.route_color}` : '#2563eb';
const textColor = r.route_text_color ? `#${r.route_text_color}` : '#ffffff';
const isBus = String(r.route_type) === '3' || (r.route_type >= 700 && r.route_type <= 716);
return (
<div key={r.route_id} className={`gtfs-accordion-item ${isOpen ? 'open' : ''} ${selectedInRoute > 0 ? 'has-selected' : ''}`}>
<div className="gtfs-accordion-header" onClick={() => handleToggleAccordionRoute(r.route_id)}>
<div className="gtfs-accordion-header-left">
<input
type="checkbox"
checked={isAllRouteSelected}
ref={el => {
if (el) el.indeterminate = isPartiallySelected;
}}
onChange={(e) => {
e.stopPropagation();
handleToggleRouteSelection(r.route_id, !isAllRouteSelected);
}}
onClick={(e) => e.stopPropagation()}
style={{ width: 18, height: 18, cursor: 'pointer' }}
/>
<span className="gtfs-route-badge" style={{ backgroundColor: bgColor, color: textColor }}>
{r.route_short_name || r.route_id}
</span>
<span className="gtfs-route-type-badge">
{isBus ? '🚌 Substitution' : '🚆 Train'}
</span>
<span className="gtfs-accordion-route-name">
{r.route_long_name || r.route_short_name || r.route_id}
</span>
</div>
<div className="gtfs-accordion-header-right">
<span className={`gtfs-accordion-count-badge ${selectedInRoute > 0 ? 'active' : ''}`}>
<wcs-mat-icon icon="schedule" size="s" /> {selectedInRoute} / {routeTrips.length} circulation(s)
</span>
<button
type="button"
className="gtfs-accordion-expand-btn"
aria-label={isOpen ? 'Replier' : 'Déplier'}
>
<wcs-mat-icon icon={isOpen ? 'expand_less' : 'expand_more'} size="m" />
</button>
</div>
</div>
{isOpen && (
<div className="gtfs-accordion-body">
<div className="gtfs-accordion-body-toolbar">
<div style={{ fontSize: '0.82rem', color: '#64748b' }}>
{routeTrips.length} circulations répertoriées sur cette ligne
</div>
<div style={{ display: 'flex', gap: 8 }}>
<wcs-button
mode="stroked"
size="s"
onClick={() => handleToggleRouteSelection(r.route_id, !isAllRouteSelected)}
>
{isAllRouteSelected ? 'Désélectionner la ligne' : 'Sélectionner toute la ligne'}
</wcs-button>
<wcs-button
mode="stroked"
size="s"
onClick={() => handleSelectGTFSRoute(r.route_id)}
>
Vue détaillée →
</wcs-button>
</div>
</div>
<div className="gtfs-accordion-table-wrapper">
<table className="gtfs-accordion-table">
<thead>
<tr>
<th style={{ width: 40, textAlign: 'center' }}>
<input
type="checkbox"
checked={isAllRouteSelected}
ref={el => {
if (el) el.indeterminate = isPartiallySelected;
}}
onChange={() => handleToggleRouteSelection(r.route_id, !isAllRouteSelected)}
/>
</th>
<th>Train / Sillon</th>
<th>Départ</th>
<th>Arrivée</th>
<th>Arrêts</th>
<th>Jours de circulation</th>
<th>Service Annuel</th>
</tr>
</thead>
<tbody>
{routeTrips.map(trip => {
const isTripSelected = gtfsSelectedTripIds.has(trip.trip_id);
const stopTimes = gtfsParsedData.stopTimesByTrip[trip.trip_id] || [];
const firstStop = stopTimes[0];
const lastStop = stopTimes[stopTimes.length - 1];
const firstStopName = firstStop ? (gtfsParsedData.stopsById[firstStop.stop_id]?.stop_name || firstStop.stop_id) : '—';
const lastStopName = lastStop ? (gtfsParsedData.stopsById[lastStop.stop_id]?.stop_name || lastStop.stop_id) : '—';
const depTime = firstStop ? formatGTFSTime(firstStop.departure_time) : '--:--';
const arrTime = lastStop ? formatGTFSTime(lastStop.arrival_time) : '--:--';
const cal = gtfsParsedData.calendarByService[trip.service_id];
const matchedSA = matchServiceAnnuelForGTFSTrip(
trip,
gtfsParsedData.calendarByService,
gtfsParsedData.calendarDatesByService,
servicesAnnuels
);
return (
<tr
key={trip.trip_id}
className={`gtfs-accordion-trip-row ${isTripSelected ? 'selected' : ''}`}
onClick={() => handleToggleGTFSTrip(trip.trip_id)}
>
<td style={{ textAlign: 'center' }} onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isTripSelected}
onChange={() => handleToggleGTFSTrip(trip.trip_id)}
style={{ width: 16, height: 16, cursor: 'pointer' }}
/>
</td>
<td>
<strong style={{ color: '#1e293b' }}>
{trip.trip_headsign || trip.trip_short_name || trip.trip_id}
</strong>
{trip.trip_short_name && trip.trip_headsign && trip.trip_short_name !== trip.trip_headsign && (
<div style={{ fontSize: '0.72rem', color: '#64748b' }}>{trip.trip_short_name}</div>
)}
</td>
<td>
<span className="gtfs-trip-time">{depTime}</span>
<span style={{ fontWeight: 500, marginLeft: 6 }}>{firstStopName}</span>
</td>
<td>
<span className="gtfs-trip-time">{arrTime}</span>
<span style={{ fontWeight: 500, marginLeft: 6 }}>{lastStopName}</span>
</td>
<td>
<span className="norm-badge-freq" style={{ fontSize: '0.75rem' }}>
{stopTimes.length} arrêts
</span>
</td>
<td>
{cal ? (
<div>
<div className="gtfs-trip-days-badges" style={{ gap: 2 }}>
{['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map((dayKey, idx) => {
const labels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'];
const active = cal[dayKey] === '1';
return (
<span key={dayKey} className={`gtfs-day-pill ${active ? 'active' : 'inactive'}`} style={{ width: 18, height: 18, fontSize: '0.65rem' }}>
{labels[idx]}
</span>
);
})}
</div>
<div style={{ fontSize: '0.7rem', color: '#64748b', marginTop: 2 }}>
{formatGTFSDate(cal.start_date)} → {formatGTFSDate(cal.end_date)}
</div>
</div>
) : (
<span className="norm-badge-ponctuel" style={{ fontSize: '0.72rem' }}>Ponctuel</span>
)}
</td>
<td>
{matchedSA ? (
<span className="norm-badge-freq" style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', fontSize: '0.75rem' }}>
📅 {matchedSA.nom}
</span>
) : (
<span className="norm-badge-ponctuel" style={{ fontSize: '0.72rem' }}>⚠️ Hors SA</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
})
)}
</div>
)}
{/* GRID VIEW MODE */}
{gtfsViewMode === 'grid' && (
<div className="gtfs-routes-grid">
{filteredRoutes.map(r => {
const routeTrips = (gtfsParsedData.tripsByRoute[r.route_id] || []);
const tripsCount = routeTrips.length;
const selectedInRoute = routeTrips.filter(t => gtfsSelectedTripIds.has(t.trip_id)).length;
const bgColor = r.route_color ? `#${r.route_color}` : '#2563eb';
const textColor = r.route_text_color ? `#${r.route_text_color}` : '#ffffff';
const isBus = String(r.route_type) === '3' || (r.route_type >= 700 && r.route_type <= 716);
return (
<div
key={r.route_id}
className={`gtfs-route-card ${selectedInRoute > 0 ? 'selected' : ''}`}
onClick={() => handleSelectGTFSRoute(r.route_id)}
>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="gtfs-route-badge" style={{ backgroundColor: bgColor, color: textColor }}>
{r.route_short_name || r.route_id}
</span>
<span className="gtfs-route-type-badge">
{isBus ? '🚌 Substitution' : '🚆 Train'}
</span>
</div>
<div className="gtfs-route-name">
{r.route_long_name || r.route_short_name || r.route_id}
</div>
</div>
<div style={{ marginTop: 14, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="gtfs-route-trips-count">
<wcs-mat-icon icon="schedule" size="s" /> {selectedInRoute > 0 ? `${selectedInRoute} / ${tripsCount}` : `${tripsCount}`} circulation(s)
</div>
<wcs-button mode="stroked" size="s">Sélectionner →</wcs-button>
</div>
</div>
);
})}
</div>
)}
{/* STICKY BOTTOM ACTION BAR FOR MULTI-SELECTION */}
{totalSelectedTripsCount > 0 && (
<div className="gtfs-sticky-action-bar">
<div className="gtfs-action-info">
<wcs-mat-icon icon="check_circle" size="m" style={{ color: '#38bdf8' }} />
<div>
<div className="gtfs-action-count">
{totalSelectedTripsCount} circulation(s) sélectionnée(s)
</div>
<div style={{ fontSize: '0.8rem', color: '#94a3b8' }}>
Réparties sur {selectedRoutesCount} ligne(s)
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<wcs-button
mode="stroked"
style={{ color: '#cbd5e1', borderColor: '#475569' }}
onClick={() => setGtfsSelectedTripIds(new Set())}
>
Tout désélectionner
</wcs-button>
<wcs-button
className="btn-gradient"
disabled={gtfsConverting}
onClick={handleConvertAndImportGTFSTrips}
>
{gtfsConverting ? (
<>
<wcs-spinner size="s" />
<span>Conversion en cours...</span>
</>
) : (
<>
<span>Convertir et Importer les {totalSelectedTripsCount} circulations</span>
<wcs-mat-icon icon="arrow_forward" size="s" />
</>
)}
</wcs-button>
</div>
</div>
)}
</div>
);
})()}
</div>
)}
+187 -79
View File
@@ -82,15 +82,197 @@ export function isCirculationCar(circ) {
}
/**
* Formate les jours de circulation en libellé compact conforme au modèle officiel
* Formate les jours de circulation en libellé compact conforme au modèle officiel SNCF / TER
*/
export function formatCirculationDaysCompact(horaire) {
if (!horaire) return 'Tous les jours';
// 0. Si l'utilisateur a explicitement personnalisé les jours pour cette circulation
if (horaire._custom_jours && typeof horaire._custom_jours === 'string') {
return horaire._custom_jours.trim();
}
const parseBool = (v) => {
if (typeof v === 'boolean') return v;
if (typeof v === 'number') return v === 1;
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
return s === 'true' || s === 't' || s === '1' || s === 'oui' || s === 'yes' || s === 'y';
}
return false;
};
const numStr = String(horaire.numero_train || '').trim();
// 1. Table de référence exacte des circulations TER BFC / AURA (Ligne Dijon <-> Seurre <-> Bourg-en-Bresse)
const specificTrainDays = {
// 1. Analyse approfondie des jours personnalisés (dates réelles dans la base)
const jp = horaire.jours_personnalises;
if (Array.isArray(jp) && jp.length > 0) {
const activeDates = jp.filter(j => j && j.circule !== false && j.date);
if (activeDates.length > 0) {
const dowsCount = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
activeDates.forEach(j => {
const dStr = String(j.date).slice(0, 10);
const parts = dStr.split('-');
if (parts.length === 3) {
const dt = new Date(Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10), 12, 0, 0));
const dow = dt.getUTCDay(); // 0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat
if (!isNaN(dow)) dowsCount[dow]++;
}
});
const maxCount = Math.max(...Object.values(dowsCount));
// Un jour de la semaine est considéré comme actif s'il représente au moins 10% des occurrences maximales
const threshold = Math.max(1, maxCount * 0.1);
const hasSun = dowsCount[0] >= threshold;
const hasMon = dowsCount[1] >= threshold;
const hasTue = dowsCount[2] >= threshold;
const hasWed = dowsCount[3] >= threshold;
const hasThu = dowsCount[4] >= threshold;
const hasFri = dowsCount[5] >= threshold;
const hasSat = dowsCount[6] >= threshold;
const activeDows = [hasSun, hasMon, hasTue, hasWed, hasThu, hasFri, hasSat].filter(Boolean).length;
if (activeDows === 7 || (hasMon && hasTue && hasWed && hasThu && hasFri && hasSat && hasSun)) {
return 'Tous les jours';
}
if (hasMon && hasTue && hasWed && hasThu && hasFri && !hasSat && !hasSun) {
return 'Lun à Ven*';
}
if (hasMon && hasTue && hasWed && hasThu && hasFri && hasSat && !hasSun) {
return 'Lun à Sam*';
}
if (!hasMon && !hasTue && !hasWed && !hasThu && !hasFri && hasSat && hasSun) {
return 'Sam et Dim';
}
if (activeDows === 1 && hasSun) {
return 'Dim et Fêtes';
}
if (activeDows === 1 && hasSat) {
return 'Sam';
}
if (activeDows === 1 && hasFri) {
return 'Ven';
}
if (hasFri && hasSun && !hasMon && !hasTue && !hasWed && !hasThu && !hasSat) {
return 'Ven, Dim et Fêtes';
}
if (hasMon && hasTue && hasWed && hasThu && !hasFri && !hasSat && !hasSun) {
return 'Lun à Jeu';
}
if (hasMon && hasTue && hasWed && hasThu && hasFri && !hasSat && hasSun) {
return 'Sauf Sam';
}
if (!hasMon && hasTue && hasWed && hasThu && hasFri && hasSat && !hasSun) {
return 'Mar à Sam';
}
const activeLabels = [];
if (hasMon) activeLabels.push('Lun');
if (hasTue) activeLabels.push('Mar');
if (hasWed) activeLabels.push('Mer');
if (hasThu) activeLabels.push('Jeu');
if (hasFri) activeLabels.push('Ven');
if (hasSat) activeLabels.push('Sam');
if (hasSun) activeLabels.push('Dim');
if (activeLabels.length > 0 && activeLabels.length < 7) {
return activeLabels.join(', ');
}
}
}
// 2. Analyse des booléens de jours de circulation
const cLu = parseBool(horaire.circule_lundi);
const cMa = parseBool(horaire.circule_mardi);
const cMe = parseBool(horaire.circule_mercredi);
const cJe = parseBool(horaire.circule_jeudi);
const cVe = parseBool(horaire.circule_vendredi);
const cSa = parseBool(horaire.circule_samedi);
const cDi = parseBool(horaire.circule_dimanche);
const cFer = parseBool(horaire.circule_jours_feries) || parseBool(horaire.circule_dimanches_feries);
const anyDayTrue = cLu || cMa || cMe || cJe || cVe || cSa || cDi;
if (anyDayTrue) {
const allWeek = cLu && cMa && cMe && cJe && cVe && cSa && cDi;
const lu_ve = cLu && cMa && cMe && cJe && cVe && !cSa && !cDi;
const lu_sa = cLu && cMa && cMe && cJe && cVe && cSa && !cDi;
const sa_di = !cLu && !cMa && !cMe && !cJe && !cVe && cSa && cDi;
const di_only = !cLu && !cMa && !cMe && !cJe && !cVe && !cSa && cDi;
const sa_only = !cLu && !cMa && !cMe && !cJe && !cVe && cSa && !cDi;
const ve_di = !cLu && !cMa && !cMe && !cJe && cVe && !cSa && cDi;
const ve_only = !cLu && !cMa && !cMe && !cJe && cVe && !cSa && !cDi;
const lu_je = cLu && cMa && cMe && cJe && !cVe && !cSa && !cDi;
const sauf_sa = cLu && cMa && cMe && cJe && cVe && !cSa && cDi;
const mar_sa = !cLu && cMa && cMe && cJe && cVe && cSa && !cDi;
if (allWeek) return 'Tous les jours';
if (lu_ve) return cFer ? 'Lun à Ven' : 'Lun à Ven*';
if (lu_sa) return cFer ? 'Lun à Sam' : 'Lun à Sam*';
if (sa_di) return cFer ? 'Sam, Dim et Fêtes' : 'Sam et Dim';
if (di_only) return cFer ? 'Dim et Fêtes' : 'Dim';
if (ve_di) return cFer ? 'Ven, Dim et Fêtes' : 'Ven et Dim';
if (sa_only) return 'Sam';
if (ve_only) return 'Ven';
if (lu_je) return 'Lun à Jeu';
if (sauf_sa) return 'Sauf Sam';
if (mar_sa) return 'Mar à Sam';
// Cas de 4 jours ouvrés (ex: Lu, Ma, Je, Ve)
if (cLu && cMa && !cMe && cJe && cVe && !cSa && !cDi) return 'Lu, Ma, Je, Ve';
if (cLu && !cMa && cMe && cJe && cVe && !cSa && !cDi) return 'Lu, Me, Je, Ve';
if (cLu && cMa && cMe && !cJe && cVe && !cSa && !cDi) return 'Lu, Ma, Me, Ve';
// Combinaisons de 2 jours
if (cMe && cVe && !cLu && !cMa && !cJe && !cSa && !cDi) return 'Mer, Ven';
if (cLu && cVe && !cMa && !cMe && !cJe && !cSa && !cDi) return 'Lun, Ven';
if (cLu && cMe && !cMa && !cJe && !cVe && !cSa && !cDi) return 'Lun, Mer';
// Jour unique
if (cLu && !cMa && !cMe && !cJe && !cVe && !cSa && !cDi) return 'Lun';
if (!cLu && cMa && !cMe && !cJe && !cVe && !cSa && !cDi) return 'Mar';
if (!cLu && !cMa && cMe && !cJe && !cVe && !cSa && !cDi) return 'Mer';
if (!cLu && !cMa && !cMe && cJe && !cVe && !cSa && !cDi) return 'Jeu';
const activeDays = [];
if (cLu) activeDays.push('Lu');
if (cMa) activeDays.push('Ma');
if (cMe) activeDays.push('Me');
if (cJe) activeDays.push('Je');
if (cVe) activeDays.push('Ve');
if (cSa) activeDays.push('Sa');
if (cDi) activeDays.push('Di');
if (activeDays.length > 0) return activeDays.join(', ');
}
// 3. Détection par champ texte (jours_circulation, frequence, periodicite)
const textDay = String(horaire.jours_circulation || horaire.frequence || horaire.periodicite || '').trim();
if (textDay) {
const tLower = textDay.toLowerCase();
if (/lun.*ven.*fer|lun.*ven\*/i.test(tLower)) return 'Lun à Ven*';
if (/lun.*ven/i.test(tLower) && !/sam/i.test(tLower) && !/dim/i.test(tLower)) return 'Lun à Ven';
if (/lun.*sam.*fer|lun.*sam\*/i.test(tLower)) return 'Lun à Sam*';
if (/lun.*sam/i.test(tLower) && !/dim/i.test(tLower)) return 'Lun à Sam';
if (/ven.*dim.*f[eê]t/i.test(tLower)) return 'Ven, Dim et Fêtes';
if (/dim.*f[eê]t/i.test(tLower)) return 'Dim et Fêtes';
if (/sam.*dim.*f[eê]t/i.test(tLower)) return 'Sam, Dim et Fêtes';
if (/sam.*dim|week-?end/i.test(tLower) && !/lun/i.test(tLower)) return 'Sam et Dim';
if (/sauf.*sam/i.test(tLower)) return 'Sauf Sam';
if (/sauf.*dim/i.test(tLower)) return 'Lun à Sam*';
if (/^dim(anche)?$/i.test(tLower)) return 'Dim';
if (/^sam(edi)?$/i.test(tLower)) return 'Sam';
if (/^ven(dredi)?$/i.test(tLower)) return 'Ven';
if (/^mer(credi)?$/i.test(tLower)) return 'Mer';
if (/tous.*jours|quotidien|7j/i.test(tLower)) return 'Tous les jours';
if (textDay.length <= 25 && !textDay.includes(';')) {
return textDay;
}
}
// 4. Table de référence de secours pour numéros standards TER BFC / AURA
const standardTrainDays = {
'891800': 'Lun à Ven*',
'891802': 'Lun à Ven*',
'891804': 'Tous les jours',
@@ -115,84 +297,10 @@ export function formatCirculationDaysCompact(horaire) {
'895922': 'Ven, Dim et Fêtes'
};
if (numStr === '891818') {
const textCheck = String(horaire.jours_circulation || '').toLowerCase();
if (textCheck.includes('dim') || horaire.circule_dimanche === true) return 'Dim';
return 'Lun à Ven*';
if (standardTrainDays[numStr]) {
return standardTrainDays[numStr];
}
// 2. Détection par texte explicite (jours_circulation, frequence, periodicite)
const textDay = String(horaire.jours_circulation || horaire.frequence || horaire.periodicite || '').toLowerCase().trim();
if (textDay) {
if (/lun.*ven.*fer|lun.*ven\*/i.test(textDay)) return 'Lun à Ven*';
if (/lun.*ven/i.test(textDay) && !/sam/i.test(textDay) && !/dim/i.test(textDay)) return 'Lun à Ven';
if (/lun.*sam.*fer|lun.*sam\*/i.test(textDay)) return 'Lun à Sam*';
if (/lun.*sam/i.test(textDay) && !/dim/i.test(textDay)) return 'Lun à Sam';
if (/sam.*dim|week-?end/i.test(textDay) && !/lun/i.test(textDay)) return 'Sam et Dim';
if (/ven.*dim.*f[eê]t/i.test(textDay)) return 'Ven, Dim et Fêtes';
if (/dim.*f[eê]t/i.test(textDay)) return 'Dim et Fêtes';
if (/sauf.*sam/i.test(textDay)) return 'Sauf Sam';
if (/^dim(anche)?$/i.test(textDay)) return 'Dim';
if (/^sam(edi)?$/i.test(textDay)) return 'Sam';
if (/^ven(dredi)?$/i.test(textDay)) return 'Ven';
if (/tous.*jours|quotidien|7j/i.test(textDay)) return 'Tous les jours';
}
// 3. Détection par booléens ou indicateurs de jours (avec coercion robuste)
const parseBool = (v) => {
if (typeof v === 'boolean') return v;
if (typeof v === 'number') return v === 1;
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
return s === 'true' || s === 't' || s === '1' || s === 'oui';
}
return false;
};
const cLu = parseBool(horaire.circule_lundi);
const cMa = parseBool(horaire.circule_mardi);
const cMe = parseBool(horaire.circule_mercredi);
const cJe = parseBool(horaire.circule_jeudi);
const cVe = parseBool(horaire.circule_vendredi);
const cSa = parseBool(horaire.circule_samedi);
const cDi = parseBool(horaire.circule_dimanche);
const cFer = parseBool(horaire.circule_jours_feries);
const hasAnyDayBool = [cLu, cMa, cMe, cJe, cVe, cSa, cDi].some(Boolean);
if (hasAnyDayBool) {
const lu_ve = cLu && cMa && cMe && cJe && cVe && !cSa && !cDi;
const lu_sa = cLu && cMa && cMe && cJe && cVe && cSa && !cDi;
const tous = cLu && cMa && cMe && cJe && cVe && cSa && cDi;
if (tous) return 'Tous les jours';
if (lu_ve) return cFer ? 'Lun à Ven' : 'Lun à Ven*';
if (lu_sa) return cFer ? 'Lun à Sam' : 'Lun à Sam*';
if (!cLu && !cMa && !cMe && !cJe && !cVe && cSa && cDi) return 'Sam et Dim';
if (!cLu && !cMa && !cMe && !cJe && !cVe && !cSa && cDi) return cFer ? 'Dim et Fêtes' : 'Dim';
if (!cLu && !cMa && !cMe && !cJe && cVe && !cSa && cDi) return 'Ven, Dim et Fêtes';
if (cSa && !cDi && !lu_ve) return 'Sam';
if (cLu && cMa && cMe && cJe && cVe && !cSa && cDi) return 'Sauf Sam';
// Jours individuels
const days = [];
if (cLu) days.push('Lu');
if (cMa) days.push('Ma');
if (cMe) days.push('Me');
if (cJe) days.push('Je');
if (cVe) days.push('Ve');
if (cSa) days.push('Sa');
if (cDi) days.push('Di');
if (days.length > 0) return days.join(', ');
}
// 4. Si la table de référence par numéro de train s'applique
if (specificTrainDays[numStr]) {
return specificTrainDays[numStr];
}
if (textDay) return textDay;
return 'Tous les jours';
}