Files
ferrovia-panel/src/api/horaires.js
T
AdminGit 36b0c00cbc V5.0.0 //
-> Refonte du système de login
-> Récupération du MDP
-> Refonte imports d'horaires + Modale de gestion d'horaires
2026-10-01 23:44:09 +02:00

1165 lines
40 KiB
JavaScript

import supabase, { isSupabaseConfigured } from '../supabaseClient';
const normalizeGareName = (value) => String(value || '').trim().toLowerCase();
const isSameGare = (row, refId, refNom) => {
if (!row) return false;
const rowId = row.gare_id || row.id || null;
if (refId && rowId && String(rowId) === String(refId)) return true;
const refName = normalizeGareName(refNom);
return refName && normalizeGareName(row.gare_nom || row.nom) === refName;
};
const propagateQuaisByNumeroTrain = (horaires) => {
if (!horaires || horaires.length === 0) return horaires;
// 1. S'assurer que tous les horaires ont leur départ/arrivée dans gares_desservies
horaires.forEach(h => {
if (!Array.isArray(h.gares_desservies)) {
h.gares_desservies = [];
}
const snapshot = Array.isArray(h.snapshot_gares_desservies) ? h.snapshot_gares_desservies : [];
const hasDepart = h.gares_desservies.some(gd => Number(gd.ordre) === 0 || isSameGare(gd, h.gare_depart_id, h.gare_depart_nom));
if (!hasDepart && h.gare_depart_nom) {
const snapDepart = snapshot.find(s => Number(s.ordre) === 0 || s.is_depart || isSameGare(s, h.gare_depart_id, h.gare_depart_nom));
h.gares_desservies.push({
horaire_id: h.id,
gare_id: h.gare_depart_id,
gare_nom: h.gare_depart_nom,
heure_arrivee: null,
heure_depart: h.heure_depart,
ordre: 0,
quai: snapDepart ? (snapDepart.quai || null) : null,
quai_reel: snapDepart ? (snapDepart.quai_reel || null) : null,
is_depart: true
});
}
const hasArrivee = h.gares_desservies.some(gd => isSameGare(gd, h.gare_arrivee_id, h.gare_arrivee_nom));
if (!hasArrivee && h.gare_arrivee_nom) {
const snapArrivee = snapshot.find(s => s.is_arrivee || isSameGare(s, h.gare_arrivee_id, h.gare_arrivee_nom));
h.gares_desservies.push({
horaire_id: h.id,
gare_id: h.gare_arrivee_id,
gare_nom: h.gare_arrivee_nom,
heure_arrivee: h.heure_arrivee,
heure_depart: null,
ordre: snapArrivee ? (snapArrivee.ordre || 9999) : 9999,
quai: snapArrivee ? (snapArrivee.quai || null) : null,
quai_reel: snapArrivee ? (snapArrivee.quai_reel || null) : null,
is_arrivee: true
});
}
});
const groups = {};
horaires.forEach(h => {
if (h.numero_train) {
if (!groups[h.numero_train]) groups[h.numero_train] = [];
groups[h.numero_train].push(h);
}
});
Object.keys(groups).forEach(numTrain => {
const group = groups[numTrain];
if (group.length <= 1) return;
const gareQuais = {};
group.forEach(h => {
if (Array.isArray(h.gares_desservies)) {
h.gares_desservies.forEach(gd => {
const key = normalizeGareName(gd.gare_nom);
if (key) {
if (!gareQuais[key]) {
gareQuais[key] = { quai: null, quai_reel: null };
}
if (gd.quai && !gareQuais[key].quai) {
gareQuais[key].quai = gd.quai;
}
if (gd.quai_reel && !gareQuais[key].quai_reel) {
gareQuais[key].quai_reel = gd.quai_reel;
}
}
});
}
});
const hasAnyQuai = Object.values(gareQuais).some(q => q.quai || q.quai_reel);
if (!hasAnyQuai) return;
group.forEach(h => {
if (Array.isArray(h.gares_desservies)) {
h.gares_desservies.forEach(gd => {
const key = normalizeGareName(gd.gare_nom);
if (key && gareQuais[key]) {
if (!gd.quai && gareQuais[key].quai) {
gd.quai = gareQuais[key].quai;
}
if (!gd.quai_reel && gareQuais[key].quai_reel) {
gd.quai_reel = gareQuais[key].quai_reel;
}
}
});
}
});
});
// Trier de nouveau si on a inséré des gares de départ/arrivée
horaires.forEach(h => {
if (Array.isArray(h.gares_desservies)) {
h.gares_desservies.sort((a, b) => (Number(a.ordre) || 0) - (Number(b.ordre) || 0));
}
});
return horaires;
};
// ============================================================================
// API HORAIRES (SILLONS)
// ============================================================================
/**
* Récupère tous les horaires avec leurs gares desservies et matériel roulant
* @param {string} [regionId] - Filtrer par région (optionnel)
*/
export async function getAllHoraires(regionId = null) {
if (!isSupabaseConfigured) return { data: [], error: null };
try {
// Si on demande un filtrage par région, récupérer d'abord les lignes associées
let ligneIds = null;
if (regionId) {
try {
const { data: lignesData, error: lignesError } = await supabase
.from('lignes')
.select('id')
.eq('region_id', regionId);
if (lignesError) {
// Log the error but don't throw, as we can still filter by horaires.region_id
console.error('Error fetching lignes by region:', lignesError);
ligneIds = [];
} else if (Array.isArray(lignesData)) {
ligneIds = lignesData.map(l => l.id).filter(Boolean);
} else {
ligneIds = [];
}
} catch (e) {
console.error('Exception while fetching ligneIds:', e);
ligneIds = [];
}
}
const SELECT_QUERY = `
*,
materiel_roulant:materiel-roulant(id, nom, nom_technique, image_url)
`;
let orConditions = [];
if (regionId) {
if (ligneIds && ligneIds.length > 0) {
orConditions.push(`ligne_id.in.(${ligneIds.join(',')})`);
}
orConditions.push(`region_id.eq.${regionId}`);
}
let allData = [];
let start = 0;
const limit = 1000;
let hasMore = true;
let currentSelect = SELECT_QUERY;
while (hasMore) {
let query = supabase
.from('horaires')
.select(currentSelect)
.order('created_at', { ascending: false })
.range(start, start + limit - 1);
if (regionId) {
query = query.or(orConditions.join(','));
}
const { data, error } = await query;
if (error) {
if (currentSelect !== '*' && (error.message?.includes('materiel-roulant') || error.message?.includes('relationship') || error.code === 'PGRST200')) {
console.warn('Relation materiel-roulant absente du cache de schéma PostgREST, repli sur SELECT *.');
currentSelect = '*';
allData = [];
start = 0;
continue;
}
console.error('Erreur getAllHoraires fetch:', error);
return { data: [], error };
}
if (data && data.length > 0) {
allData = allData.concat(data);
start += limit;
}
if (!data || data.length < limit) {
hasMore = false;
}
}
// Normaliser gares_desservies depuis la colonne JSONB unique
const normalizedData = (allData || []).map(h => {
const gares = Array.isArray(h.gares_desservies) ? [...h.gares_desservies] : [];
gares.sort((a, b) => (Number(a.ordre) || 0) - (Number(b.ordre) || 0));
return {
...h,
gares_desservies: gares
};
});
const propagatedData = propagateQuaisByNumeroTrain(normalizedData);
return { data: propagatedData, error: null };
} catch (err) {
console.error('Erreur getAllHoraires:', err);
return { data: [], error: err };
}
}
/**
* Récupère un horaire par son ID depuis la table unique horaires
*/
export async function getHoraireById(id) {
if (!isSupabaseConfigured) return { data: null, error: { message: 'Supabase non configuré' } };
try {
let { data, error } = await supabase
.from('horaires')
.select(`
*,
materiel_roulant:materiel-roulant(id, nom, nom_technique, image_url)
`)
.eq('id', id)
.single();
if (error && (error.message?.includes('materiel-roulant') || error.message?.includes('relationship') || error.code === 'PGRST200')) {
const fallback = await supabase
.from('horaires')
.select('*')
.eq('id', id)
.single();
data = fallback.data;
error = fallback.error;
}
if (error) return { data: null, error };
if (data) {
const gares = Array.isArray(data.gares_desservies) ? [...data.gares_desservies] : [];
gares.sort((a, b) => (Number(a.ordre) || 0) - (Number(b.ordre) || 0));
const hCopy = { ...data, gares_desservies: gares };
const propagated = propagateQuaisByNumeroTrain([hCopy]);
return { data: propagated[0], error: null };
}
return { data: null, error: null };
} catch (err) {
console.error('Erreur getHoraireById:', err);
return { data: null, error: err };
}
}
/**
* Crée un nouvel horaire avec ses gares desservies
* @param {Object} payload - Données de l'horaire
* @param {Array} garesDesservies - Liste des gares desservies [{gare_nom, heure_arrivee, heure_depart, ordre}, ...]
* @param {Object|null} currentUser - L'utilisateur actuellement connecté
*/
export async function createHoraire(payload, garesDesservies = [], currentUser = null) {
if (!isSupabaseConfigured) {
const fakeId = `local-${Date.now()}`;
return { data: { id: fakeId, ...payload, gares_desservies: garesDesservies }, error: null };
}
try {
// Si l'utilisateur est un exploitant, pré-attribuer sa région
if (currentUser && currentUser.role === 'exploitant' && currentUser.region_id) {
payload.region_id = currentUser.region_id;
}
// Construire le snapshot JSON des gares desservies
const garesDesserviesJson = (garesDesservies || []).map((g, idx) => ({
gare_id: g.gare_id || null,
gare_nom: g.gare_nom || '',
heure_arrivee: g.heure_arrivee || null,
heure_depart: g.heure_depart || null,
ordre: g.ordre ?? idx + 1,
quai: g.quai || null,
quai_reel: g.quai_reel || null
}));
// Ajouter les entrées départ et arrivée
const departEntry = {
gare_id: payload.gare_depart_id || null,
gare_nom: payload.gare_depart_nom || '',
heure_arrivee: null,
heure_depart: payload.heure_depart || null,
ordre: 0,
quai: null,
quai_reel: null,
is_depart: true
};
const maxOrd = garesDesserviesJson.reduce((m, e) => Math.max(m, Number(e.ordre || 0)), 0);
const arriveeEntry = {
gare_id: payload.gare_arrivee_id || null,
gare_nom: payload.gare_arrivee_nom || '',
heure_arrivee: payload.heure_arrivee || null,
heure_depart: null,
ordre: maxOrd + 1,
quai: null,
quai_reel: null,
is_arrivee: true
};
const fullGaresDesserviesJson = [departEntry, ...garesDesserviesJson, arriveeEntry];
// Construire le dictionnaire O(1) attributions_quais
const attributionsQuais = {};
fullGaresDesserviesJson.forEach(g => {
const key = g.gare_id || g.gare_nom;
if (key && (g.quai || g.quai_reel)) {
attributionsQuais[key] = {
quai: g.quai || null,
quai_reel: g.quai_reel || null,
ordre: g.ordre
};
}
});
// Insérer de manière atomique dans la table unique horaires
let { data: horaire, error: horaireError } = await supabase
.from('horaires')
.insert([{
...payload,
gares_desservies: fullGaresDesserviesJson,
attributions_quais: attributionsQuais,
created_at: new Date(),
updated_at: new Date()
}])
.select(`
*,
materiel_roulant:materiel-roulant(id, nom, nom_technique, image_url)
`)
.single();
if (horaireError && (horaireError.message?.includes('materiel-roulant') || horaireError.message?.includes('relationship') || horaireError.code === 'PGRST200')) {
const fallback = await supabase
.from('horaires')
.insert([{
...payload,
gares_desservies: fullGaresDesserviesJson,
attributions_quais: attributionsQuais,
created_at: new Date(),
updated_at: new Date()
}])
.select('*')
.single();
horaire = fallback.data;
horaireError = fallback.error;
}
if (horaireError) {
console.error('Erreur création horaire:', horaireError);
return { data: null, error: horaireError };
}
return { data: horaire, error: null };
} catch (err) {
console.error('Exception createHoraire:', err);
return { data: null, error: err };
}
}
export async function updateHorairesLigneBatch(ids, ligne_id, ligne_nom) {
if (!isSupabaseConfigured) return { error: null };
try {
const { error } = await supabase
.from('horaires')
.update({ ligne_id, ligne_nom, updated_at: new Date() })
.in('id', ids);
return { error };
} catch (err) {
console.error('Exception updateHorairesLigneBatch:', err);
return { error: err };
}
}
/**
* Met à jour un horaire existant et ses gares desservies dans la table unique
* @param {number|string} id - ID de l'horaire
* @param {Object} payload - Données à mettre à jour
* @param {Array} garesDesservies - Nouvelle liste des gares desservies (remplace l'existante)
*/
export async function updateHoraire(id, payload, garesDesservies = null) {
if (!isSupabaseConfigured) {
return { data: { id, ...payload }, error: null };
}
try {
let updateFields = {
...payload,
updated_at: new Date()
};
if (garesDesservies !== null) {
const garesDesserviesJson = (garesDesservies || []).map((g, idx) => ({
gare_id: g.gare_id || null,
gare_nom: g.gare_nom || '',
heure_arrivee: g.heure_arrivee || null,
heure_depart: g.heure_depart || null,
ordre: g.ordre ?? idx + 1,
quai: g.quai || null,
quai_reel: g.quai_reel || null
}));
const hasDepart = garesDesserviesJson.some(g => Number(g.ordre) === 0 || g.is_depart);
const departEntry = hasDepart ? null : {
gare_id: payload.gare_depart_id || null,
gare_nom: payload.gare_depart_nom || '',
heure_arrivee: null,
heure_depart: payload.heure_depart || null,
ordre: 0,
quai: null,
quai_reel: null,
is_depart: true
};
const maxOrd = garesDesserviesJson.reduce((m, e) => Math.max(m, Number(e.ordre || 0)), 0);
const hasArrivee = garesDesserviesJson.some(g => g.is_arrivee);
const arriveeEntry = hasArrivee ? null : {
gare_id: payload.gare_arrivee_id || null,
gare_nom: payload.gare_arrivee_nom || '',
heure_arrivee: payload.heure_arrivee || null,
heure_depart: null,
ordre: maxOrd + 1,
quai: null,
quai_reel: null,
is_arrivee: true
};
const fullList = [
...(departEntry ? [departEntry] : []),
...garesDesserviesJson,
...(arriveeEntry ? [arriveeEntry] : [])
].sort((a, b) => (Number(a.ordre) || 0) - (Number(b.ordre) || 0));
const attributionsQuais = {};
fullList.forEach(g => {
const key = g.gare_id || g.gare_nom;
if (key && (g.quai || g.quai_reel)) {
attributionsQuais[key] = {
quai: g.quai || null,
quai_reel: g.quai_reel || null,
ordre: g.ordre
};
}
});
updateFields.gares_desservies = fullList;
updateFields.attributions_quais = attributionsQuais;
}
// Mettre à jour l'horaire de manière atomique
let { data: updated, error: updateError } = await supabase
.from('horaires')
.update(updateFields)
.eq('id', id)
.select(`
*,
materiel_roulant:materiel-roulant(id, nom, nom_technique, image_url)
`)
.single();
if (updateError && (updateError.message?.includes('materiel-roulant') || updateError.message?.includes('relationship') || updateError.code === 'PGRST200')) {
const fallback = await supabase
.from('horaires')
.update(updateFields)
.eq('id', id)
.select('*')
.single();
updated = fallback.data;
updateError = fallback.error;
}
if (updateError) {
console.error('Erreur mise à jour horaire:', updateError);
return { data: null, error: updateError };
}
return { data: updated, error: null };
} catch (err) {
console.error('Exception updateHoraire:', err);
return { data: null, error: err };
}
}
/**
* Met à jour uniquement la composition du train pour un horaire.
* Utilisé par la page d'attribution du matériel roulant.
*/
export async function updateHoraireComposition(id, compositionTrain = []) {
if (!isSupabaseConfigured) {
return { data: { id, composition_train: compositionTrain }, error: null };
}
try {
const normalized = (compositionTrain || []).map((item, idx) => ({
materiel_id: item?.materiel_id,
materiel_nom: item?.materiel_nom || '',
materiel_nom_technique: item?.materiel_nom_technique || '',
materiel_image_url: item?.materiel_image_url || null,
position: idx + 1
}));
const { data, error } = await supabase
.from('horaires')
.update({ composition_train: normalized, updated_at: new Date() })
.eq('id', id)
.select('*')
.single();
return { data, error };
} catch (err) {
console.error('Exception updateHoraireComposition:', err);
return { data: null, error: err };
}
}
/**
* Supprime un horaire (hard delete)
* @param {number|string} id - ID de l'horaire
*/
export async function deleteHoraire(id) {
if (!isSupabaseConfigured) return { error: null };
try {
const { error } = await supabase
.from('horaires')
.delete()
.eq('id', id);
return { error };
} catch (err) {
console.error('Exception deleteHoraire:', err);
return { error: err };
}
}
// ============================================================================
// API GARES DESSERVIES (GESTION DANS LA TABLE UNIQUE HORAIRES)
// ============================================================================
/**
* Ajoute une gare desservie directement dans l'horaire (colonne JSONB gares_desservies)
*/
export async function addGareDesservie(horaireId, gareData) {
if (!isSupabaseConfigured) {
return { data: { id: `local-${Date.now()}`, horaire_id: horaireId, ...gareData }, error: null };
}
try {
const { data: horaire, error: readErr } = await supabase
.from('horaires')
.select('gares_desservies, attributions_quais')
.eq('id', horaireId)
.single();
if (readErr) return { data: null, error: readErr };
const gares = Array.isArray(horaire?.gares_desservies) ? [...horaire.gares_desservies] : [];
const newGare = {
gare_id: gareData.gare_id || null,
gare_nom: gareData.gare_nom || '',
heure_arrivee: gareData.heure_arrivee || null,
heure_depart: gareData.heure_depart || null,
ordre: gareData.ordre ?? gares.length + 1,
quai: gareData.quai || null,
quai_reel: gareData.quai_reel || null
};
gares.push(newGare);
gares.sort((a, b) => (Number(a.ordre) || 0) - (Number(b.ordre) || 0));
const attributionsQuais = { ...(horaire?.attributions_quais || {}) };
const key = newGare.gare_id || newGare.gare_nom;
if (key && (newGare.quai || newGare.quai_reel)) {
attributionsQuais[key] = {
quai: newGare.quai || null,
quai_reel: newGare.quai_reel || null,
ordre: newGare.ordre
};
}
const { error: updateErr } = await supabase
.from('horaires')
.update({
gares_desservies: gares,
attributions_quais: attributionsQuais,
updated_at: new Date()
})
.eq('id', horaireId);
return { data: newGare, error: updateErr };
} catch (err) {
console.error('Exception addGareDesservie:', err);
return { data: null, error: err };
}
}
/**
* Met à jour une gare desservie dans l'horaire
*/
export async function updateGareDesservie(horaireId, gareData) {
if (!isSupabaseConfigured) {
return { data: gareData, error: null };
}
try {
const { data: horaire, error: readErr } = await supabase
.from('horaires')
.select('gares_desservies, attributions_quais')
.eq('id', horaireId)
.single();
if (readErr) return { data: null, error: readErr };
const gares = Array.isArray(horaire?.gares_desservies) ? [...horaire.gares_desservies] : [];
const idx = gares.findIndex(g => (g.gare_id && g.gare_id === gareData.gare_id) || Number(g.ordre) === Number(gareData.ordre));
if (idx !== -1) {
gares[idx] = { ...gares[idx], ...gareData };
} else {
gares.push(gareData);
}
gares.sort((a, b) => (Number(a.ordre) || 0) - (Number(b.ordre) || 0));
const attributionsQuais = { ...(horaire?.attributions_quais || {}) };
const key = gareData.gare_id || gareData.gare_nom;
if (key && (gareData.quai || gareData.quai_reel)) {
attributionsQuais[key] = {
quai: gareData.quai || null,
quai_reel: gareData.quai_reel || null,
ordre: gareData.ordre
};
}
const { error } = await supabase
.from('horaires')
.update({
gares_desservies: gares,
attributions_quais: attributionsQuais,
updated_at: new Date()
})
.eq('id', horaireId);
return { data: gareData, error };
} catch (err) {
console.error('Exception updateGareDesservie:', err);
return { data: null, error: err };
}
}
/**
* Supprime une gare desservie de l'horaire
*/
export async function deleteGareDesservie(horaireId, target) {
if (!isSupabaseConfigured) return { error: null };
try {
const { data: horaire, error: readErr } = await supabase
.from('horaires')
.select('gares_desservies, attributions_quais')
.eq('id', horaireId)
.single();
if (readErr) return { error: readErr };
let gares = Array.isArray(horaire?.gares_desservies) ? [...horaire.gares_desservies] : [];
gares = gares.filter(g => g.gare_id !== target && Number(g.ordre) !== Number(target));
const { error } = await supabase
.from('horaires')
.update({ gares_desservies: gares, updated_at: new Date() })
.eq('id', horaireId);
return { error };
} catch (err) {
console.error('Exception deleteGareDesservie:', err);
return { error: err };
}
}
/**
* Met à jour les quais directement dans la table unique horaires
*
* @param {string|number} horaireId - ID de l'horaire
* @param {Object} quaisParOrdre - Map { ordre -> quai_nom } pour les gares intermédiaires
* + clés spéciales "depart" et "arrivee"
*/
export async function updateQuaisHoraire(horaireId, quaisParOrdre, isPropagation = false) {
if (!isSupabaseConfigured) {
return { data: null, error: null };
}
try {
// 1. Lire directement depuis la table unique horaires
const { data: horaire, error: readErr } = await supabase
.from('horaires')
.select('numero_train, gares_desservies, attributions_quais, gare_depart_id, gare_depart_nom, gare_arrivee_id, gare_arrivee_nom, heure_depart, heure_arrivee')
.eq('id', horaireId)
.single();
if (readErr) {
console.error('Erreur lecture horaire:', readErr);
return { data: null, error: readErr };
}
const baseRows = Array.isArray(horaire.gares_desservies) ? [...horaire.gares_desservies] : [];
const existingDepartRow = baseRows.find(r => Number(r.ordre) === 0 || r.is_depart)
|| baseRows.find(r => isSameGare(r, horaire.gare_depart_id, horaire.gare_depart_nom) && r.heure_depart);
const rowsSansDepart = baseRows.filter(r => r !== existingDepartRow);
const maxOrdreBase = rowsSansDepart.reduce((m, e) => Math.max(m, Number(e.ordre || 0)), 0);
const existingArriveeRow = rowsSansDepart.find(r => r.is_arrivee)
|| rowsSansDepart.find(r => Number(r.ordre) === maxOrdreBase && isSameGare(r, horaire.gare_arrivee_id, horaire.gare_arrivee_nom))
|| rowsSansDepart.find(r => isSameGare(r, horaire.gare_arrivee_id, horaire.gare_arrivee_nom) && (r.heure_arrivee || !r.heure_depart));
const updatedIntermediaires = rowsSansDepart
.filter(r => r !== existingArriveeRow)
.map((g, idx) => ({
...g,
quai: (quaisParOrdre[g.ordre ?? idx + 1] !== undefined)
? (quaisParOrdre[g.ordre ?? idx + 1] || null)
: (g.quai || null)
}));
const maxOrdre = updatedIntermediaires.reduce((m, e) => Math.max(m, Number(e.ordre || 0)), 0);
const arriveeOrdre = maxOrdre + 1;
// Construire les entrées départ et arrivée avec les quais mis à jour
const departEntry = {
...(existingDepartRow || {}),
gare_id: existingDepartRow?.gare_id || horaire.gare_depart_id || null,
gare_nom: existingDepartRow?.gare_nom || horaire.gare_depart_nom || '',
heure_arrivee: null,
heure_depart: existingDepartRow?.heure_depart || horaire.heure_depart || null,
ordre: 0,
quai: (quaisParOrdre && Object.prototype.hasOwnProperty.call(quaisParOrdre, 'depart'))
? (quaisParOrdre.depart || null)
: (existingDepartRow?.quai || null),
quai_reel: existingDepartRow?.quai_reel || null,
is_depart: true
};
const arriveeEntry = {
...(existingArriveeRow || {}),
gare_id: existingArriveeRow?.gare_id || horaire.gare_arrivee_id || null,
gare_nom: existingArriveeRow?.gare_nom || horaire.gare_arrivee_nom || '',
heure_arrivee: existingArriveeRow?.heure_arrivee || horaire.heure_arrivee || null,
heure_depart: null,
ordre: arriveeOrdre,
quai: (quaisParOrdre && Object.prototype.hasOwnProperty.call(quaisParOrdre, 'arrivee'))
? (quaisParOrdre.arrivee || null)
: (existingArriveeRow?.quai || null),
quai_reel: existingArriveeRow?.quai_reel || null,
is_arrivee: true
};
const fullSnapshot = [departEntry, ...updatedIntermediaires, arriveeEntry];
// Mettre à jour attributions_quais O(1)
const attributionsQuais = { ...(horaire.attributions_quais || {}) };
fullSnapshot.forEach(g => {
const key = g.gare_id || g.gare_nom;
if (key) {
attributionsQuais[key] = {
quai: g.quai || null,
quai_reel: g.quai_reel || null,
ordre: g.ordre
};
}
});
// Sauvegarder dans la table unique horaires en UNE SEULE requête
const { data: saved, error: saveErr } = await supabase
.from('horaires')
.update({
gares_desservies: fullSnapshot,
attributions_quais: attributionsQuais,
updated_at: new Date()
})
.eq('id', horaireId)
.select('gares_desservies')
.single();
if (saveErr) {
console.error('Erreur sauvegarde quais dans horaires:', saveErr);
return { data: null, error: saveErr };
}
// Propager aux autres horaires ayant le même numéro de train si applicable
if (!isPropagation && horaire.numero_train) {
try {
const { data: others } = await supabase
.from('horaires')
.select('id')
.eq('numero_train', horaire.numero_train)
.neq('id', horaireId);
if (others && others.length > 0) {
for (const other of others) {
await updateQuaisHoraire(other.id, quaisParOrdre, true);
}
}
} catch (propErr) {
console.error('Erreur propagation updateQuaisHoraire:', propErr);
}
}
return { data: saved?.gares_desservies || fullSnapshot, error: null };
} catch (err) {
console.error('Exception updateQuaisHoraire:', err);
return { data: null, error: err };
}
}
/**
* Mise à jour groupée des quais pour plusieurs horaires.
* @param {Array<{horaireId: string|number, payload: Object}>} updates
*/
export async function updateQuaisBatch(updates) {
if (!isSupabaseConfigured) return { error: null };
try {
const results = [];
// Groupe les payloads par horaireId pour minimiser les appels
const byHoraire = {};
updates.forEach(u => {
if (!byHoraire[u.horaireId]) byHoraire[u.horaireId] = {};
Object.assign(byHoraire[u.horaireId], u.payload);
});
// On traite par paquets de 5 pour ne pas saturer Supabase tout en restant rapide
const horaireIds = Object.keys(byHoraire);
const CHUNK_SIZE = 5;
for (let i = 0; i < horaireIds.length; i += CHUNK_SIZE) {
const chunk = horaireIds.slice(i, i + CHUNK_SIZE);
await Promise.all(chunk.map(async (hid) => {
const { data, error } = await updateQuaisHoraire(hid, byHoraire[hid]);
results.push({ horaireId: hid, data, error });
}));
}
return { data: results, error: null };
} catch (err) {
console.error('Exception updateQuaisBatch:', err);
return { error: err };
}
}
// ============================================================================
// UTILITAIRES
// ============================================================================
/**
* Récupère tous les types de trains disponibles (pour le select)
*/
export function getTypesTrains() {
return [
{ value: 'TER', label: 'TER' },
{ value: 'TGV', label: 'TGV' },
{ value: 'Intercités', label: 'Intercités' },
{ value: 'Ouigo', label: 'Ouigo' },
{ value: 'Transilien', label: 'Transilien' },
{ value: 'RER', label: 'RER' },
{ value: 'Autocar', label: 'Autocar de substitution' },
{ value: 'Autre', label: 'Autre' }
];
}
/**
* Formate une heure pour l'affichage (HH:MM)
*/
export function formatHeure(time) {
if (!time) return '-';
// Si c'est déjà au format HH:MM ou HH:MM:SS
if (typeof time === 'string') {
const parts = time.split(':');
if (parts.length >= 2) {
return `${parts[0].padStart(2, '0')}:${parts[1].padStart(2, '0')}`;
}
}
return time;
}
/**
* Récupère le nom des jours de circulation d'un horaire
*/
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 (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 };
}
try {
// 1. Lire le JSONB actuel depuis horaires
const { data: horaire, error: readErr } = await supabase
.from('horaires')
.select('numero_train, gares_desservies, gare_depart_id, gare_depart_nom, gare_arrivee_id, gare_arrivee_nom, heure_depart, heure_arrivee')
.eq('id', horaireId)
.single();
if (readErr) {
console.error('Erreur lecture horaire:', readErr);
return { data: null, error: readErr };
}
// 2. Utiliser directement le snapshot JSONB existant
const baseRows = Array.isArray(horaire.gares_desservies) ? [...horaire.gares_desservies] : [];
const existingDepartRow = baseRows.find(r => Number(r.ordre) === 0 || r.is_depart)
|| baseRows.find(r => isSameGare(r, horaire.gare_depart_id, horaire.gare_depart_nom) && r.heure_depart);
const rowsSansDepart = baseRows.filter(r => r !== existingDepartRow);
const maxOrdreBase = rowsSansDepart.reduce((m, e) => Math.max(m, Number(e.ordre || 0)), 0);
const existingArriveeRow = rowsSansDepart.find(r => r.is_arrivee)
|| rowsSansDepart.find(r => Number(r.ordre) === maxOrdreBase && isSameGare(r, horaire.gare_arrivee_id, horaire.gare_arrivee_nom))
|| rowsSansDepart.find(r => isSameGare(r, horaire.gare_arrivee_id, horaire.gare_arrivee_nom) && (r.heure_arrivee || !r.heure_depart));
const updatedIntermediaires = rowsSansDepart
.filter(r => r !== existingArriveeRow)
.map((g, idx) => ({
...g,
quai_reel: (quaisParOrdre[g.ordre ?? idx + 1] !== undefined)
? (quaisParOrdre[g.ordre ?? idx + 1] || null)
: (g.quai_reel || null)
}));
const maxOrdre = updatedIntermediaires.reduce((m, e) => Math.max(m, Number(e.ordre || 0)), 0);
const arriveeOrdre = maxOrdre + 1;
// 3. Construire les entrées spéciales pour départ et arrivée (si présentes)
const departEntry = {
id: existingDepartRow?.id || null,
gare_id: existingDepartRow?.gare_id || horaire.gare_depart_id || null,
gare_nom: existingDepartRow?.gare_nom || horaire.gare_depart_nom || '',
heure_arrivee: null,
heure_depart: existingDepartRow?.heure_depart || horaire.heure_depart || null,
ordre: 0,
quai: existingDepartRow?.quai || null,
quai_reel: (quaisParOrdre && Object.prototype.hasOwnProperty.call(quaisParOrdre, 'depart'))
? (quaisParOrdre.depart || null)
: (existingDepartRow?.quai_reel || null),
is_depart: true
};
const arriveeEntry = {
id: existingArriveeRow?.id || null,
gare_id: existingArriveeRow?.gare_id || horaire.gare_arrivee_id || null,
gare_nom: existingArriveeRow?.gare_nom || horaire.gare_arrivee_nom || '',
heure_arrivee: existingArriveeRow?.heure_arrivee || horaire.heure_arrivee || null,
heure_depart: null,
ordre: arriveeOrdre,
quai: existingArriveeRow?.quai || null,
quai_reel: (quaisParOrdre && Object.prototype.hasOwnProperty.call(quaisParOrdre, 'arrivee'))
? (quaisParOrdre.arrivee || null)
: (existingArriveeRow?.quai_reel || null),
is_arrivee: true
};
// 4. Construire le snapshot complet : départ + intermédiaires + arrivée
const fullSnapshot = [departEntry, ...updatedIntermediaires, arriveeEntry];
// 5. Écrire le JSONB mis à jour dans horaires.gares_desservies
const { data: saved, error: saveErr } = await supabase
.from('horaires')
.update({ gares_desservies: fullSnapshot, updated_at: new Date() })
.eq('id', horaireId)
.select('gares_desservies')
.single();
if (saveErr) {
console.error('Erreur sauvegarde quais dans horaires.gares_desservies:', saveErr);
return { data: null, error: saveErr };
}
// 6. Propager aux autres horaires ayant le même numéro de train
if (!isPropagation && horaire.numero_train) {
try {
const { data: others } = await supabase
.from('horaires')
.select('id')
.eq('numero_train', horaire.numero_train)
.neq('id', horaireId);
if (others && others.length > 0) {
for (const other of others) {
await updateQuaisReelsHoraire(other.id, quaisParOrdre, true);
}
}
} catch (propErr) {
console.error('Erreur lors de la propagation de updateQuaisReelsHoraire:', propErr);
}
}
return { data: saved.gares_desservies, error: null };
} catch (err) {
console.error('Exception updateQuaisReelsHoraire:', err);
return { data: null, error: err };
}
}
/**
* Récupère le nombre d'horaires attribués à chaque quai d'une gare depuis la table unique horaires.
* @param {string} gareId
* @param {string} gareNom
* @returns {Promise<{data: Object<string, number>, error: any}>} Map de nom de quai -> compte
*/
export async function getHorairesCountByQuai(gareId, gareNom) {
if (!isSupabaseConfigured) return { data: {}, error: null };
try {
const { data, error } = await supabase
.from('horaires')
.select('gares_desservies, attributions_quais');
if (error) return { data: {}, error };
const counts = {};
if (data) {
data.forEach(h => {
if (h.attributions_quais && gareId && h.attributions_quais[gareId]?.quai) {
const q = h.attributions_quais[gareId].quai;
counts[q] = (counts[q] || 0) + 1;
} else if (Array.isArray(h.gares_desservies)) {
const match = h.gares_desservies.find(g =>
(gareId && g.gare_id === gareId) ||
(gareNom && normalizeGareName(g.gare_nom) === normalizeGareName(gareNom))
);
if (match?.quai) {
counts[match.quai] = (counts[match.quai] || 0) + 1;
}
}
});
}
return { data: counts, error: null };
} catch (err) {
return { data: {}, error: err };
}
}
/**
* Réassigne une gare source vers une gare cible directement dans la table unique horaires.
*/
export async function reassignGareInHoraires(sourceId, targetId, targetNom) {
if (!isSupabaseConfigured) return { error: null };
try {
// 1. Mettre à jour les colonnes d'origine et destination dans horaires
await supabase.from('horaires').update({ gare_depart_id: targetId, gare_depart_nom: targetNom }).eq('gare_depart_id', sourceId);
await supabase.from('horaires').update({ gare_arrivee_id: targetId, gare_arrivee_nom: targetNom }).eq('gare_arrivee_id', sourceId);
// 2. Mettre à jour le JSONB gares_desservies et attributions_quais
const { data: horaires } = await supabase
.from('horaires')
.select('id, gares_desservies, attributions_quais');
if (horaires) {
for (const h of horaires) {
if (Array.isArray(h.gares_desservies)) {
let modified = false;
const newGaresDesservies = h.gares_desservies.map(gd => {
if (gd.gare_id === sourceId) {
modified = true;
return { ...gd, gare_id: targetId, gare_nom: targetNom };
}
return gd;
});
if (modified) {
const attributionsQuais = { ...(h.attributions_quais || {}) };
if (attributionsQuais[sourceId]) {
attributionsQuais[targetId] = attributionsQuais[sourceId];
delete attributionsQuais[sourceId];
}
await supabase
.from('horaires')
.update({
gares_desservies: newGaresDesservies,
attributions_quais: attributionsQuais,
updated_at: new Date()
})
.eq('id', h.id);
}
}
}
}
return { error: null };
} catch (err) {
console.error('Exception reassignGareInHoraires:', err);
return { error: err };
}
}