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:
1 parent
1c68740e41
commit
a7f0c96435
7 files changed
+1028
-126
No files matched your search
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
if (!gtfsParsedData) return;
|
||||
|
||||
let selectedTrips = [];
|
||||
let titleName = 'Lignes GTFS';
|
||||
|
||||
if (gtfsSelectedRouteId && gtfsStep === 4) {
|
||||
const allRouteTrips = gtfsParsedData.tripsByRoute[gtfsSelectedRouteId] || [];
|
||||
const selectedTrips = allRouteTrips.filter(t => gtfsSelectedTripIds.has(t.trip_id));
|
||||
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,20 +3103,76 @@ 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>
|
||||
{(() => {
|
||||
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;
|
||||
});
|
||||
|
||||
const totalSelectedTripsCount = gtfsSelectedTripIds.size;
|
||||
const selectedRoutesCount = new Set(
|
||||
(gtfsParsedData.trips || [])
|
||||
.filter(t => gtfsSelectedTripIds.has(t.trip_id))
|
||||
.map(t => t.route_id)
|
||||
).size;
|
||||
|
||||
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 }}>
|
||||
<h3 className="step-title" style={{ margin: 0 }}><wcs-mat-icon icon="alt_route" size="m" /> 3. Choix de la ligne à importer</h3>
|
||||
<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 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>
|
||||
|
||||
<div className="gtfs-filter-bar">
|
||||
{/* 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={{ maxWidth: 360 }}
|
||||
placeholder="Rechercher par numéro de ligne, nom..."
|
||||
style={{ minWidth: 260, maxWidth: 360 }}
|
||||
placeholder="Rechercher par nom de ligne, numéro, arrêt..."
|
||||
value={gtfsRouteSearch}
|
||||
onChange={(e) => setGtfsRouteSearch(e.target.value)}
|
||||
/>
|
||||
@@ -3076,27 +3195,248 @@ export default function ImportsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;
|
||||
<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>
|
||||
);
|
||||
})
|
||||
.map(r => {
|
||||
const tripsCount = (gtfsParsedData.tripsByRoute[r.route_id] || []).length;
|
||||
)}
|
||||
</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 ${gtfsSelectedRouteId === r.route_id ? 'selected' : ''}`}
|
||||
className={`gtfs-route-card ${selectedInRoute > 0 ? 'selected' : ''}`}
|
||||
onClick={() => handleSelectGTFSRoute(r.route_id)}
|
||||
>
|
||||
<div>
|
||||
@@ -3114,7 +3454,7 @@ export default function ImportsPage() {
|
||||
</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)
|
||||
<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>
|
||||
@@ -3122,6 +3462,53 @@ export default function ImportsPage() {
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user