From 36b0c00cbc06b6fb2ffa63e58f3d920969793d78 Mon Sep 17 00:00:00 2001 From: Admin Git Date: Thu, 1 Oct 2026 23:44:09 +0200 Subject: [PATCH] V5.0.0 // MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -> Refonte du système de login -> Récupération du MDP -> Refonte imports d'horaires + Modale de gestion d'horaires --- SQL/scheme.sql | 110 +- src/App.js | 46 +- src/api/horaires.js | 756 ++++---- src/api/horaires.test.js | 137 ++ src/api/horairesSubstitutions.js | 121 +- src/api/utilisateurs/index.js | 114 +- .../HoraireModal/CirculationCalendar.jsx | 16 +- src/components/HoraireModal/HoraireModal.css | 635 +++++++ src/components/HoraireModal/HoraireModal.jsx | 1578 +++++++++++------ .../HoraireModal/HoraireModal.test.js | 185 ++ src/pages/horaires/ImportsPage.css | 308 ++++ src/pages/horaires/ImportsPage.jsx | 943 +++++++++- src/pages/horaires/ImportsPage.test.js | 132 ++ src/pages/login/Login.css | 8 + src/pages/login/Login.js | 75 +- .../ReinitialiserMotDePasse.css | 81 + .../ReinitialiserMotDePasse.js | 362 ++++ src/utils/userUtils.js | 553 +++--- src/utils/userUtils.test.js | 60 + supabase/EMAIL_SETUP_SELF_HOSTED.md | 152 ++ supabase/config.toml | 4 + ...1230000_optimize_single_table_horaires.sql | 328 ++++ supabase/templates/recovery.html | 252 +++ 23 files changed, 5557 insertions(+), 1399 deletions(-) create mode 100644 src/api/horaires.test.js create mode 100644 src/components/HoraireModal/HoraireModal.css create mode 100644 src/components/HoraireModal/HoraireModal.test.js create mode 100644 src/pages/horaires/ImportsPage.test.js create mode 100644 src/pages/reinitialiser-mot-de-passe/ReinitialiserMotDePasse.css create mode 100644 src/pages/reinitialiser-mot-de-passe/ReinitialiserMotDePasse.js create mode 100644 supabase/EMAIL_SETUP_SELF_HOSTED.md create mode 100644 supabase/migrations/20261001230000_optimize_single_table_horaires.sql create mode 100644 supabase/templates/recovery.html diff --git a/SQL/scheme.sql b/SQL/scheme.sql index 6643fd2..e7d1f96 100644 --- a/SQL/scheme.sql +++ b/SQL/scheme.sql @@ -257,49 +257,76 @@ CREATE INDEX IF NOT EXISTS "idx_lignes_gares_ligne" ON "public"."lignes_gares" ( CREATE INDEX IF NOT EXISTS "idx_lignes_gares_ordre" ON "public"."lignes_gares" ("ligne_id","ordre"); -- ----------------------------------------------- --- 6.11 horaires +-- 6.11 horaires (Table unique et consolidée) -- ----------------------------------------------- CREATE TABLE IF NOT EXISTS "public"."horaires" ( - "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, - "numero_train" varchar(50) NOT NULL, - "type_train" varchar(255), - "type_train_id" bigint REFERENCES "public"."types_train"("id") ON DELETE SET NULL, - "gare_depart_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, - "gare_depart_nom" varchar(255), - "heure_depart" time, - "gare_arrivee_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, - "gare_arrivee_nom" varchar(255), - "heure_arrivee" time, - "circule_lundi" boolean DEFAULT true, - "circule_mardi" boolean DEFAULT true, - "circule_mercredi" boolean DEFAULT true, - "circule_jeudi" boolean DEFAULT true, - "circule_vendredi" boolean DEFAULT true, - "circule_samedi" boolean DEFAULT true, - "circule_dimanche" boolean DEFAULT true, - "circule_jours_feries" boolean DEFAULT false, - "circule_dimanches_feries" boolean DEFAULT false, - "jours_personnalises" jsonb DEFAULT '[]'::jsonb, - "jours_non_circulation" jsonb DEFAULT '[]'::jsonb, - "materiel_roulant_id" bigint REFERENCES "public"."materiel-roulant"("id") ON DELETE SET NULL, - "ligne_id" uuid REFERENCES "public"."lignes"("id") ON DELETE SET NULL, - "ligne_nom" varchar(255), - "service_annuel_id" uuid REFERENCES "public"."services_annuels"("id") ON DELETE SET NULL, - "service_annuel_nom" varchar(255), - "est_substitution" boolean DEFAULT false, - "motif_substitution" varchar(255), - "gares_desservies" jsonb DEFAULT '[]'::jsonb, - "composition_train" jsonb DEFAULT '[]'::jsonb, - "substitution_disponible" boolean DEFAULT false, - "actif" boolean DEFAULT true, - "region_id" varchar(100), - "created_at" timestamptz DEFAULT now(), - "updated_at" timestamptz DEFAULT now(), - "deleted_at" timestamptz + "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + "numero_train" varchar(50) NOT NULL, + "nom_train" varchar(255), + "code_mission" varchar(50), + "sens" varchar(20) DEFAULT 'aller', + "type_train" varchar(100), + "type_train_id" bigint REFERENCES "public"."types_train"("id") ON DELETE SET NULL, + "transporteur" varchar(100) DEFAULT 'SNCF Voyageurs', + "gare_depart_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "gare_depart_nom" varchar(255) NOT NULL, + "heure_depart" time, + "gare_arrivee_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL, + "gare_arrivee_nom" varchar(255) NOT NULL, + "heure_arrivee" time, + "gares_desservies" jsonb NOT NULL DEFAULT '[]'::jsonb, + "attributions_quais" jsonb NOT NULL DEFAULT '{}'::jsonb, + "circule_lundi" boolean DEFAULT true, + "circule_mardi" boolean DEFAULT true, + "circule_mercredi" boolean DEFAULT true, + "circule_jeudi" boolean DEFAULT true, + "circule_vendredi" boolean DEFAULT true, + "circule_samedi" boolean DEFAULT true, + "circule_dimanche" boolean DEFAULT true, + "circule_jours_feries" boolean DEFAULT false, + "circule_dimanches_feries" boolean DEFAULT false, + "date_debut" timestamptz, + "date_fin" timestamptz, + "jours_personnalises" jsonb NOT NULL DEFAULT '[]'::jsonb, + "jours_non_circulation" jsonb NOT NULL DEFAULT '[]'::jsonb, + "calendrier_circulation" jsonb NOT NULL DEFAULT '[]'::jsonb, + "periode_validite" jsonb NOT NULL DEFAULT '{}'::jsonb, + "materiel_roulant_id" bigint REFERENCES "public"."materiel-roulant"("id") ON DELETE SET NULL, + "composition_train" jsonb NOT NULL DEFAULT '[]'::jsonb, + "ligne_id" uuid REFERENCES "public"."lignes"("id") ON DELETE SET NULL, + "ligne_nom" varchar(255), + "region_id" varchar(100), + "region" varchar(255), + "service_annuel_id" uuid REFERENCES "public"."services_annuels"("id") ON DELETE SET NULL, + "service_annuel_nom" varchar(255), + "service_type" varchar(50) DEFAULT 'TER', + "est_substitution" boolean NOT NULL DEFAULT false, + "motif_substitution" varchar(255), + "substitution_disponible" boolean NOT NULL DEFAULT false, + "horaire_id" uuid REFERENCES "public"."horaires"("id") ON DELETE SET NULL, + "substitutions_associees" jsonb NOT NULL DEFAULT '[]'::jsonb, + "sillon_id" text, + "tranche" varchar(50), + "gestionnaire_infrastructure" varchar(100) DEFAULT 'SNCF Réseau', + "vitesse_max_kmh" integer, + "statut" varchar(50) NOT NULL DEFAULT 'a_l_heure', + "retard_minutes" integer NOT NULL DEFAULT 0, + "retard_motif" text, + "informations_voyageurs" text, + "actif" boolean NOT NULL DEFAULT true, + "metadata" jsonb NOT NULL DEFAULT '{}'::jsonb, + "notes" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz ); + +-- Index haute performance B-Tree et GIN CREATE INDEX IF NOT EXISTS "idx_horaires_numero_train" ON "public"."horaires" ("numero_train"); CREATE INDEX IF NOT EXISTS "idx_horaires_gare_depart" ON "public"."horaires" ("gare_depart_id"); CREATE INDEX IF NOT EXISTS "idx_horaires_gare_arrivee" ON "public"."horaires" ("gare_arrivee_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_departs" ON "public"."horaires" ("gare_depart_id", "heure_depart"); +CREATE INDEX IF NOT EXISTS "idx_horaires_arrivees" ON "public"."horaires" ("gare_arrivee_id", "heure_arrivee"); CREATE INDEX IF NOT EXISTS "idx_horaires_actif" ON "public"."horaires" ("actif") WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS "idx_horaires_type_train" ON "public"."horaires" ("type_train_id"); CREATE INDEX IF NOT EXISTS "idx_horaires_materiel" ON "public"."horaires" ("materiel_roulant_id"); @@ -307,6 +334,15 @@ CREATE INDEX IF NOT EXISTS "idx_horaires_ligne" ON "public"."horaires" ("ligne_i CREATE INDEX IF NOT EXISTS "idx_horaires_service_annuel" ON "public"."horaires" ("service_annuel_id"); CREATE INDEX IF NOT EXISTS "idx_horaires_substitution" ON "public"."horaires" ("est_substitution"); CREATE INDEX IF NOT EXISTS "idx_horaires_region_id" ON "public"."horaires" ("region_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_dates" ON "public"."horaires" ("date_debut", "date_fin"); +CREATE INDEX IF NOT EXISTS "idx_horaires_statut" ON "public"."horaires" ("statut"); + +-- Index GIN JSONB pour recherche et filtrage ultra-rapide sans jointure +CREATE INDEX IF NOT EXISTS "idx_horaires_gares_desservies_gin" ON "public"."horaires" USING gin ("gares_desservies" jsonb_path_ops); +CREATE INDEX IF NOT EXISTS "idx_horaires_attributions_quais_gin" ON "public"."horaires" USING gin ("attributions_quais"); +CREATE INDEX IF NOT EXISTS "idx_horaires_calendrier_gin" ON "public"."horaires" USING gin ("calendrier_circulation" jsonb_path_ops); +CREATE INDEX IF NOT EXISTS "idx_horaires_metadata_gin" ON "public"."horaires" USING gin ("metadata"); + -- ----------------------------------------------- -- 6.12 horaires_gares_desservies diff --git a/src/App.js b/src/App.js index 40e1957..684f971 100644 --- a/src/App.js +++ b/src/App.js @@ -3,8 +3,10 @@ import './App.css'; import { applyPolyfills, defineCustomElements } from 'wcs-core/loader'; import Login from './pages/login/Login'; import ActiverCompte from './pages/activer-compte/ActiverCompte'; +import ReinitialiserMotDePasse from './pages/reinitialiser-mot-de-passe/ReinitialiserMotDePasse'; import PanelRouter from './pages/router/PanelRouter'; import AppHeader from './components/header/AppHeader'; +import { buildUserProfileFromAuth } from './utils/userUtils'; import supabase from './supabaseClient'; @@ -32,6 +34,20 @@ function App() { document.documentElement.setAttribute('data-theme', 'light'); } + // Synchronisation de session Supabase Auth au démarrage + supabase.auth.getSession().then(({ data: { session } }) => { + if (session?.user && !window.location.pathname.startsWith('/reinitialiser-mot-de-passe')) { + const stored = window.localStorage.getItem('app_user'); + if (!stored) { + const user = buildUserProfileFromAuth(session.user); + if (user && user.actif !== false) { + try { window.localStorage.setItem('app_user', JSON.stringify(user)); } catch (_) {} + setCurrentUser(user); + } + } + } + }).catch(() => {}); + // écouter un événement custom pour synchroniser l'état d'authentification const onAuthChanged = () => { try { @@ -59,9 +75,20 @@ function App() { if (event === 'SIGNED_OUT') { window.localStorage.removeItem('app_user'); setCurrentUser(null); - if (window.location.pathname !== '/') { + if (window.location.pathname !== '/' && !window.location.pathname.startsWith('/reinitialiser-mot-de-passe')) { window.location.href = '/'; } + } else if (event === 'SIGNED_IN' || event === 'USER_UPDATED') { + if (session?.user && !window.location.pathname.startsWith('/reinitialiser-mot-de-passe')) { + const stored = window.localStorage.getItem('app_user'); + if (!stored) { + const user = buildUserProfileFromAuth(session.user); + if (user && user.actif !== false) { + try { window.localStorage.setItem('app_user', JSON.stringify(user)); } catch (_) {} + setCurrentUser(user); + } + } + } } }); @@ -87,15 +114,26 @@ function App() { // Routing minimal sans react-router. const isActivationPath = pathname.startsWith('/activer-compte'); - const isPanelPath = pathname.startsWith('/dashboard') + const isResetPasswordPath = pathname.startsWith('/reinitialiser-mot-de-passe') || pathname.startsWith('/reset-password'); + const isPanelPath = !isActivationPath && !isResetPasswordPath && ( + pathname.startsWith('/dashboard') || pathname.startsWith('/logout') - || currentUser; + || currentUser + ); return ( <> - {isPanelPath ? : (isActivationPath ? : )} + {isPanelPath ? ( + + ) : isResetPasswordPath ? ( + + ) : isActivationPath ? ( + + ) : ( + + )} ); } diff --git a/src/api/horaires.js b/src/api/horaires.js index 193d0eb..9248139 100644 --- a/src/api/horaires.js +++ b/src/api/horaires.js @@ -154,9 +154,7 @@ export async function getAllHoraires(regionId = null) { const SELECT_QUERY = ` *, - snapshot_gares_desservies:gares_desservies, - materiel_roulant:materiel-roulant(id, nom, nom_technique, image_url), - gares_desservies:horaires_gares_desservies(*) + materiel_roulant:materiel-roulant(id, nom, nom_technique, image_url) `; let orConditions = []; @@ -171,11 +169,12 @@ export async function getAllHoraires(regionId = null) { let start = 0; const limit = 1000; let hasMore = true; + let currentSelect = SELECT_QUERY; while (hasMore) { let query = supabase .from('horaires') - .select(SELECT_QUERY) + .select(currentSelect) .order('created_at', { ascending: false }) .range(start, start + limit - 1); @@ -186,6 +185,13 @@ export async function getAllHoraires(regionId = null) { 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 }; } @@ -200,36 +206,17 @@ export async function getAllHoraires(regionId = null) { } } - const data = allData; - - // Fusionner quai_reel depuis le snapshot JSONB dans les gares desservies relationnelles - const mergedData = (data || []).map(h => { - const snapshot = Array.isArray(h.snapshot_gares_desservies) ? h.snapshot_gares_desservies : []; - let relRows = Array.isArray(h.gares_desservies) ? h.gares_desservies : []; - - const useSnapshotFallback = relRows.length === 0; - const rowsToMap = useSnapshotFallback ? snapshot : relRows; - - const enriched = rowsToMap.map(row => { - const snapRow = useSnapshotFallback ? row : (snapshot.find(s => Number(s.ordre) === Number(row.ordre)) - || snapshot.find(s => isSameGare(s, row.gare_id, row.gare_nom))); - return { - ...row, - quai: snapRow ? (snapRow.quai || row.quai || null) : (row.quai || null), - quai_reel: snapRow ? (snapRow.quai_reel || null) : null - }; - }); - - const hCopy = { ...h }; - hCopy.gares_desservies = enriched.sort((a, b) => (a.ordre || 0) - (b.ordre || 0)); - return hCopy; - }); - - const propagatedData = propagateQuaisByNumeroTrain(mergedData); - propagatedData.forEach(h => { - delete h.snapshot_gares_desservies; + // 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); @@ -238,51 +225,42 @@ export async function getAllHoraires(regionId = null) { } /** - * Récupère un horaire par son ID avec ses gares desservies + * 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 { - const { data, error } = await supabase + let { data, error } = await supabase .from('horaires') .select(` *, - snapshot_gares_desservies:gares_desservies, - materiel_roulant:materiel-roulant(id, nom, nom_technique, image_url), - gares_desservies:horaires_gares_desservies(*) + 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 snapshot = Array.isArray(data.snapshot_gares_desservies) ? data.snapshot_gares_desservies : []; - let relRows = Array.isArray(data.gares_desservies) ? data.gares_desservies : []; - - const useSnapshotFallback = relRows.length === 0; - const rowsToMap = useSnapshotFallback ? snapshot : relRows; - - const enriched = rowsToMap.map(row => { - const snapRow = useSnapshotFallback ? row : (snapshot.find(s => Number(s.ordre) === Number(row.ordre)) - || snapshot.find(s => isSameGare(s, row.gare_id, row.gare_nom))); - return { - ...row, - quai: snapRow ? (snapRow.quai || row.quai || null) : (row.quai || null), - quai_reel: snapRow ? (snapRow.quai_reel || null) : null - }; - }); - - const hCopy = { ...data }; - hCopy.gares_desservies = enriched.sort((a, b) => (a.ordre || 0) - (b.ordre || 0)); - + 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]); - delete propagated[0].snapshot_gares_desservies; return { data: propagated[0], error: null }; } - return { data, error: null }; + return { data: null, error: null }; } catch (err) { console.error('Erreur getHoraireById:', err); return { data: null, error: err }; @@ -314,10 +292,11 @@ export async function createHoraire(payload, garesDesservies = [], currentUser = heure_arrivee: g.heure_arrivee || null, heure_depart: g.heure_depart || null, ordre: g.ordre ?? idx + 1, - quai: g.quai || null + quai: g.quai || null, + quai_reel: g.quai_reel || null })); - // Ajouter des entrées spéciales départ / arrivée dans le snapshot (ordre 0 et max+1), sans quai initial + // Ajouter les entrées départ et arrivée const departEntry = { gare_id: payload.gare_depart_id || null, gare_nom: payload.gare_depart_nom || '', @@ -325,85 +304,74 @@ export async function createHoraire(payload, garesDesservies = [], currentUser = 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: (garesDesserviesJson.reduce((m, e) => Math.max(m, Number(e.ordre || 0)), 0) + 1) || 1, + ordre: maxOrd + 1, quai: null, + quai_reel: null, is_arrivee: true }; const fullGaresDesserviesJson = [departEntry, ...garesDesserviesJson, arriveeEntry]; - // Créer l'horaire principal (avec snapshot JSON des gares desservies) - const { data: horaire, error: horaireError } = await supabase + // 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('*') + .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 }; } - // Créer les gares desservies (incluant départ/arrivée) - if (horaire) { - const maxOrdre = garesDesserviesJson.reduce((m, e) => Math.max(m, Number(e.ordre || 0)), 0); - const departRel = { - horaire_id: horaire.id, - 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 - }; - const arriveeRel = { - horaire_id: horaire.id, - gare_id: payload.gare_arrivee_id || null, - gare_nom: payload.gare_arrivee_nom || '', - heure_arrivee: payload.heure_arrivee || null, - heure_depart: null, - ordre: maxOrdre + 1, - quai: null - }; - const garesPayload = [ - departRel, - ...(garesDesservies || []).map((g, idx) => ({ - horaire_id: horaire.id, - 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 - })), - arriveeRel - ]; - - const { error: garesError } = await supabase - .from('horaires_gares_desservies') - .insert(garesPayload); - - if (garesError) { - console.warn('Erreur création gares desservies:', garesError); - // On ne bloque pas, l'horaire est créé - } - } - - // Recharger l'horaire complet avec ses relations - const { data: fullHoraire } = await getHoraireById(horaire.id); - return { data: fullHoraire || horaire, error: null }; + return { data: horaire, error: null }; } catch (err) { console.error('Exception createHoraire:', err); @@ -426,7 +394,7 @@ export async function updateHorairesLigneBatch(ids, ligne_id, ligne_nom) { } /** - * Met à jour un horaire existant et ses gares desservies + * 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) @@ -437,134 +405,97 @@ export async function updateHoraire(id, payload, garesDesservies = null) { } try { - // Construire le snapshot JSON des gares desservies si fourni - const garesDesserviesJson = garesDesservies !== null - ? (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 - })) - : undefined; + let updateFields = { + ...payload, + updated_at: new Date() + }; - // Si on a construit le snapshot, ajouter départ/arrivée similaires à createHoraire - const garesDesserviesWithEnds = garesDesserviesJson !== undefined ? (() => { - const departEntry = { + 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 arriveeEntry = { + + 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 }; - return [departEntry, ...(garesDesserviesJson || []), arriveeEntry]; - })() : undefined; - // Mettre à jour l'horaire principal (avec snapshot JSON si gares desservies fournies) - const updatePayload = { - ...payload, - updated_at: new Date(), - ...(garesDesserviesWithEnds !== undefined ? { gares_desservies: garesDesserviesWithEnds } : {}) - }; + const fullList = [ + ...(departEntry ? [departEntry] : []), + ...garesDesserviesJson, + ...(arriveeEntry ? [arriveeEntry] : []) + ].sort((a, b) => (Number(a.ordre) || 0) - (Number(b.ordre) || 0)); - const { data: horaire, error: horaireError } = await supabase + 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(updatePayload) + .update(updateFields) .eq('id', id) - .select('*') + .select(` + *, + materiel_roulant:materiel-roulant(id, nom, nom_technique, image_url) + `) .single(); - if (horaireError) { - console.error('Erreur mise à jour horaire:', horaireError); - return { data: null, error: horaireError }; - } - - // Si garesDesservies est fourni (non null), remplacer les gares desservies - if (garesDesservies !== null) { - let existingDesservies = []; - try { - const { data: existingRows } = await supabase - .from('horaires_gares_desservies') + 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('*') - .eq('horaire_id', id); - existingDesservies = Array.isArray(existingRows) ? existingRows : []; - } catch (e) { - existingDesservies = []; - } - - const findExistingQuai = (target) => { - if (!target) return null; - const byOrdre = existingDesservies.find(r => r.ordre !== null && target.ordre !== null && Number(r.ordre) === Number(target.ordre)); - if (byOrdre && byOrdre.quai) return byOrdre.quai; - const byGare = existingDesservies.find(r => isSameGare(r, target.gare_id, target.gare_nom)); - return byGare ? byGare.quai : null; - }; - - // Supprimer les anciennes gares desservies - await supabase - .from('horaires_gares_desservies') - .delete() - .eq('horaire_id', id); - - // Insérer les nouvelles - const maxOrdre = (garesDesserviesJson || []).reduce((m, e) => Math.max(m, Number(e.ordre || 0)), 0); - const departRel = { - horaire_id: id, - 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: findExistingQuai({ ordre: 0, gare_id: payload.gare_depart_id, gare_nom: payload.gare_depart_nom }) - }; - const arriveeRel = { - horaire_id: id, - gare_id: payload.gare_arrivee_id || null, - gare_nom: payload.gare_arrivee_nom || '', - heure_arrivee: payload.heure_arrivee || null, - heure_depart: null, - ordre: maxOrdre + 1, - quai: findExistingQuai({ ordre: maxOrdre + 1, gare_id: payload.gare_arrivee_id, gare_nom: payload.gare_arrivee_nom }) - }; - const garesPayload = [ - departRel, - ...(garesDesservies || []).map((g, idx) => ({ - horaire_id: id, - 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 || findExistingQuai(g) || null - })), - arriveeRel - ]; - - const { error: garesError } = await supabase - .from('horaires_gares_desservies') - .insert(garesPayload); - - if (garesError) { - console.warn('Erreur mise à jour gares desservies:', garesError); - } + .single(); + updated = fallback.data; + updateError = fallback.error; } - // Recharger l'horaire complet avec ses relations - const { data: fullHoraire } = await getHoraireById(id); - return { data: fullHoraire || horaire, error: null }; + 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); @@ -624,10 +555,10 @@ export async function deleteHoraire(id) { } // ============================================================================ -// API GARES DESSERVIES +// API GARES DESSERVIES (GESTION DANS LA TABLE UNIQUE HORAIRES) // ============================================================================ /** - * Ajoute une gare desservie à un horaire + * Ajoute une gare desservie directement dans l'horaire (colonne JSONB gares_desservies) */ export async function addGareDesservie(horaireId, gareData) { if (!isSupabaseConfigured) { @@ -635,20 +566,47 @@ export async function addGareDesservie(horaireId, gareData) { } try { - const { data, error } = await supabase - .from('horaires_gares_desservies') - .insert([{ - horaire_id: horaireId, - 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 || 0 - }]) - .select('*') + const { data: horaire, error: readErr } = await supabase + .from('horaires') + .select('gares_desservies, attributions_quais') + .eq('id', horaireId) .single(); - return { data, error }; + 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 }; @@ -656,29 +614,51 @@ export async function addGareDesservie(horaireId, gareData) { } /** - * Met à jour une gare desservie + * Met à jour une gare desservie dans l'horaire */ -export async function updateGareDesservie(id, gareData) { +export async function updateGareDesservie(horaireId, gareData) { if (!isSupabaseConfigured) { - return { data: { id, ...gareData }, error: null }; + return { data: gareData, error: null }; } try { - const { data, error } = await supabase - .from('horaires_gares_desservies') - .update({ - gare_id: gareData.gare_id, - gare_nom: gareData.gare_nom, - heure_arrivee: gareData.heure_arrivee, - heure_depart: gareData.heure_depart, - ordre: gareData.ordre, - updated_at: new Date() - }) - .eq('id', id) - .select('*') + const { data: horaire, error: readErr } = await supabase + .from('horaires') + .select('gares_desservies, attributions_quais') + .eq('id', horaireId) .single(); - return { data, error }; + 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 }; @@ -686,11 +666,37 @@ export async function updateGareDesservie(id, gareData) { } /** - * Met à jour les quais directement dans la colonne JSONB `gares_desservies` - * de la table `horaires`. Chaque entrée du tableau reçoit un champ "quai". - * - * Structure cible dans horaires.gares_desservies : - * [{ gare_id, gare_nom, heure_arrivee, heure_depart, ordre, quai }, ...] + * 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 @@ -702,10 +708,10 @@ export async function updateQuaisHoraire(horaireId, quaisParOrdre, isPropagation } try { - // 1. Lire le JSONB actuel depuis horaires + info départ/arrivée + // 1. Lire directement depuis la table unique 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') + .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(); @@ -714,22 +720,7 @@ export async function updateQuaisHoraire(horaireId, quaisParOrdre, isPropagation return { data: null, error: readErr }; } - // 1b. Lire les lignes relationnelles existantes pour cet horaire - const { data: relRows, error: relErr } = await supabase - .from('horaires_gares_desservies') - .select('*') - .eq('horaire_id', horaireId); - - if (relErr) { - console.error('Erreur lecture horaires_gares_desservies:', relErr); - return { data: null, error: relErr }; - } - - // 2. Reconstruire les entrées à partir des données relationnelles (fallback JSON si besoin) - const relSorted = (relRows || []).sort((a, b) => (a.ordre || 0) - (b.ordre || 0)); - const baseRows = relSorted.length > 0 - ? relSorted - : (Array.isArray(horaire.gares_desservies) ? [...horaire.gares_desservies] : []); + 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); @@ -748,125 +739,16 @@ export async function updateQuaisHoraire(horaireId, quaisParOrdre, isPropagation : (g.quai || null) })); - // 3. Mettre à jour la table relationnelle `horaires_gares_desservies` - const relByOrdre = new Map(); - (relRows || []).forEach(r => { relByOrdre.set(String(r.ordre), r); }); - const maxOrdre = updatedIntermediaires.reduce((m, e) => Math.max(m, Number(e.ordre || 0)), 0); const arriveeOrdre = maxOrdre + 1; - const ops = []; - - // 3a. Intermédiaires : update existants (ou insert si manquant) - for (const entry of updatedIntermediaires) { - const ordreKey = String(entry.ordre ?? ''); - const existing = relByOrdre.get(ordreKey); - const payload = { - horaire_id: horaireId, - gare_id: entry.gare_id || null, - gare_nom: entry.gare_nom || '', - heure_arrivee: entry.heure_arrivee || null, - heure_depart: entry.heure_depart || null, - ordre: entry.ordre || null, - quai: entry.quai || null, - updated_at: new Date() - }; - - if (existing && existing.id) { - // Mettre à jour la ligne existante - ops.push( - supabase - .from('horaires_gares_desservies') - .update({ quai: payload.quai, updated_at: payload.updated_at }) - .eq('id', existing.id) - ); - } else { - // Insérer une nouvelle ligne - ops.push( - supabase - .from('horaires_gares_desservies') - .insert([payload]) - ); - } - } - - // 3b. Départ (ordre = 0) - upsert pour garder le quai en BDD - { - const departQuai = (quaisParOrdre && Object.prototype.hasOwnProperty.call(quaisParOrdre, 'depart')) - ? (quaisParOrdre.depart || null) - : (existingDepartRow?.quai || null); - const existingDepart = existingDepartRow && existingDepartRow.id ? existingDepartRow : null; - const departPayload = { - horaire_id: horaireId, - gare_id: horaire.gare_depart_id || null, - gare_nom: horaire.gare_depart_nom || '', - heure_arrivee: null, - heure_depart: horaire.heure_depart || null, - ordre: 0, - quai: departQuai, - updated_at: new Date() - }; - - if (existingDepart && existingDepart.id) { - ops.push( - supabase - .from('horaires_gares_desservies') - .update({ quai: departPayload.quai, ordre: 0, updated_at: departPayload.updated_at }) - .eq('id', existingDepart.id) - ); - } else { - ops.push( - supabase - .from('horaires_gares_desservies') - .insert([departPayload]) - ); - } - } - - // 3c. Arrivée (ordre = arriveeOrdre) - upsert pour garder le quai en BDD - { - const arriveeQuai = (quaisParOrdre && Object.prototype.hasOwnProperty.call(quaisParOrdre, 'arrivee')) - ? (quaisParOrdre.arrivee || null) - : (existingArriveeRow?.quai || null); - const existingArr = existingArriveeRow && existingArriveeRow.id ? existingArriveeRow : null; - const arriveePayload = { - horaire_id: horaireId, - gare_id: horaire.gare_arrivee_id || null, - gare_nom: horaire.gare_arrivee_nom || '', - heure_arrivee: horaire.heure_arrivee || null, - heure_depart: null, - ordre: arriveeOrdre, - quai: arriveeQuai, - updated_at: new Date() - }; - - if (existingArr && existingArr.id) { - ops.push( - supabase - .from('horaires_gares_desservies') - .update({ quai: arriveePayload.quai, ordre: arriveeOrdre, updated_at: arriveePayload.updated_at }) - .eq('id', existingArr.id) - ); - } else { - ops.push( - supabase - .from('horaires_gares_desservies') - .insert([arriveePayload]) - ); - } - } - - // Exécuter toutes les opérations relationnelles en parallèle - if (ops.length > 0) { - await Promise.all(ops); - } - - // 4. Construire les entrées spéciales pour départ et arrivée (si présentes) + // Construire les entrées départ et arrivée avec les quais mis à jour const departEntry = { - gare_id: horaire.gare_depart_id || null, - gare_nom: horaire.gare_depart_nom || '', + ...(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: horaire.heure_depart || null, + heure_depart: existingDepartRow?.heure_depart || horaire.heure_depart || null, ordre: 0, quai: (quaisParOrdre && Object.prototype.hasOwnProperty.call(quaisParOrdre, 'depart')) ? (quaisParOrdre.depart || null) @@ -876,9 +758,10 @@ export async function updateQuaisHoraire(horaireId, quaisParOrdre, isPropagation }; const arriveeEntry = { - gare_id: horaire.gare_arrivee_id || null, - gare_nom: horaire.gare_arrivee_nom || '', - heure_arrivee: horaire.heure_arrivee || null, + ...(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')) @@ -888,23 +771,39 @@ export async function updateQuaisHoraire(horaireId, quaisParOrdre, isPropagation is_arrivee: true }; - // 5. Construire le snapshot complet : départ + intermédiaires + arrivée const fullSnapshot = [departEntry, ...updatedIntermediaires, arriveeEntry]; - // 6. Écrire le JSONB mis à jour dans horaires.gares_desservies + // 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, updated_at: new Date() }) + .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.gares_desservies:', saveErr); + console.error('Erreur sauvegarde quais dans horaires:', saveErr); return { data: null, error: saveErr }; } - // 7. Propager aux autres horaires ayant le même numéro de train + // 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 @@ -919,35 +818,17 @@ export async function updateQuaisHoraire(horaireId, quaisParOrdre, isPropagation } } } catch (propErr) { - console.error('Erreur lors de la propagation de updateQuaisHoraire:', propErr); + console.error('Erreur propagation updateQuaisHoraire:', propErr); } } - return { data: saved.gares_desservies, error: null }; + return { data: saved?.gares_desservies || fullSnapshot, error: null }; } catch (err) { console.error('Exception updateQuaisHoraire:', err); return { data: null, error: err }; } } -/** - * Supprime une gare desservie - */ -export async function deleteGareDesservie(id) { - if (!isSupabaseConfigured) return { error: null }; - - try { - const { error } = await supabase - .from('horaires_gares_desservies') - .delete() - .eq('id', id); - return { error }; - } catch (err) { - console.error('Exception deleteGareDesservie:', err); - return { error: err }; - } -} - /** * Mise à jour groupée des quais pour plusieurs horaires. * @param {Array<{horaireId: string|number, payload: Object}>} updates @@ -1195,7 +1076,7 @@ export async function updateQuaisReelsHoraire(horaireId, quaisParOrdre, isPropag } /** - * Récupère le nombre d'horaires attribués à chaque quai d'une gare. + * 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, error: any}>} Map de nom de quai -> compte @@ -1203,20 +1084,25 @@ export async function updateQuaisReelsHoraire(horaireId, quaisParOrdre, isPropag export async function getHorairesCountByQuai(gareId, gareNom) { if (!isSupabaseConfigured) return { data: {}, error: null }; try { - let query = supabase.from('horaires_gares_desservies').select('quai'); - if (gareId) { - query = query.eq('gare_id', gareId); - } else if (gareNom) { - query = query.ilike('gare_nom', gareNom); - } - const { data, error } = await query; + const { data, error } = await supabase + .from('horaires') + .select('gares_desservies, attributions_quais'); if (error) return { data: {}, error }; const counts = {}; if (data) { - data.forEach(row => { - if (row.quai) { - counts[row.quai] = (counts[row.quai] || 0) + 1; + 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; + } } }); } @@ -1227,28 +1113,19 @@ export async function getHorairesCountByQuai(gareId, gareNom) { } /** - * Réassigne une gare source vers une gare cible dans tous les horaires. + * 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 relations horaires_gares_desservies - const { error: relErr } = await supabase - .from('horaires_gares_desservies') - .update({ gare_id: targetId, gare_nom: targetNom }) - .eq('gare_id', sourceId); - - if (relErr) console.error("Erreur maj horaires_gares_desservies:", relErr); - - // 2. Mettre à jour les colonnes de base dans horaires + // 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); - // 3. Mettre à jour le JSONB gares_desservies - // Puisque le jsonb est complexe à maj en SQL, on fetch et on met à jour en JS + // 2. Mettre à jour le JSONB gares_desservies et attributions_quais const { data: horaires } = await supabase .from('horaires') - .select('id, gares_desservies'); + .select('id, gares_desservies, attributions_quais'); if (horaires) { for (const h of horaires) { @@ -1262,9 +1139,18 @@ export async function reassignGareInHoraires(sourceId, targetId, 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, updated_at: new Date() }) + .update({ + gares_desservies: newGaresDesservies, + attributions_quais: attributionsQuais, + updated_at: new Date() + }) .eq('id', h.id); } } diff --git a/src/api/horaires.test.js b/src/api/horaires.test.js new file mode 100644 index 0000000..a66d928 --- /dev/null +++ b/src/api/horaires.test.js @@ -0,0 +1,137 @@ +/** + * Tests unitaires pour l'API unique consolidée des horaires et substitutions + */ + +import { + getAllHoraires, + getHoraireById, + createHoraire, + updateHoraire, + deleteHoraire, + addGareDesservie, + updateGareDesservie, + deleteGareDesservie, + updateQuaisHoraire, + getHorairesCountByQuai, + reassignGareInHoraires +} from './horaires'; + +import { + getAllSubstitutions, + getSubstitutionById, + createSubstitution, + updateSubstitution, + deleteSubstitution +} from './horairesSubstitutions'; + +// Mock Supabase comme non-configuré par défaut pour tester les fallbacks locaux +jest.mock('../supabaseClient', () => ({ + __esModule: true, + default: null +})); + +describe('API Horaires (Table unique consolidée)', () => { + describe('getAllHoraires (mode hors-ligne)', () => { + it('devrait retourner un tableau vide sans erreur si Supabase est indisponible', async () => { + const { data, error } = await getAllHoraires(); + expect(data).toEqual([]); + expect(error).toBeNull(); + }); + }); + + describe('getHoraireById (mode hors-ligne)', () => { + it('devrait retourner null avec une erreur explicite', async () => { + const { data, error } = await getHoraireById('uuid-123'); + expect(data).toBeNull(); + expect(error).toBeDefined(); + }); + }); + + describe('createHoraire (mode hors-ligne)', () => { + it('devrait générer un identifiant local et stocker les dessertes et attributions de quais dans l\'objet unique', async () => { + const payload = { + numero_train: '854120', + gare_depart_nom: 'Gare de Lyon', + gare_arrivee_nom: 'Marseille Saint-Charles', + heure_depart: '08:00', + heure_arrivee: '11:15', + type_train: 'TGV' + }; + const dessertes = [ + { gare_id: 'g-1', gare_nom: 'Gare de Lyon', ordre: 1, quai: 'A' }, + { gare_id: 'g-2', gare_nom: 'Marseille Saint-Charles', ordre: 2, quai: 'C' } + ]; + + const { data, error } = await createHoraire(payload, dessertes); + expect(error).toBeNull(); + expect(data).toBeDefined(); + expect(data.id).toMatch(/^local-/); + expect(data.numero_train).toBe('854120'); + expect(data.gares_desservies).toHaveLength(2); + expect(data.gares_desservies[0].quai).toBe('A'); + }); + }); + + describe('updateQuaisHoraire (mode hors-ligne)', () => { + it('devrait retourner null sans erreur si Supabase est indisponible', async () => { + const { data, error } = await updateQuaisHoraire('local-1', { 'depart': 'A', '1': 'B' }); + expect(error).toBeNull(); + expect(data).toBeNull(); + }); + }); + + describe('getHorairesCountByQuai (mode hors-ligne)', () => { + it('devrait retourner un objet data vide sans erreur en fallback', async () => { + const { data, error } = await getHorairesCountByQuai('gare-1', 'A'); + expect(data).toEqual({}); + expect(error).toBeNull(); + }); + }); + + describe('reassignGareInHoraires (mode hors-ligne)', () => { + it('devrait gérer la réassignation sans crash', async () => { + const result = await reassignGareInHoraires('g-old', 'g-new', 'Nouvelle Gare'); + expect(result.error).toBeNull(); + }); + }); +}); + +describe('API Substitutions (Consolidée dans la table unique horaires)', () => { + describe('getAllSubstitutions', () => { + it('devrait retourner un tableau vide sans erreur si non configuré', async () => { + const { data, error } = await getAllSubstitutions(); + expect(data).toEqual([]); + expect(error).toBeNull(); + }); + }); + + describe('getSubstitutionById', () => { + it('devrait retourner une erreur Supabase non configuré', async () => { + const { data, error } = await getSubstitutionById('sub-1'); + expect(data).toBeNull(); + expect(error).toBeDefined(); + }); + }); + + describe('createSubstitution', () => { + it('devrait créer une substitution avec le flag est_substitution=true', async () => { + const payload = { + horaire_id: 'train-123', + numero_train: 'CAR-001', + gare_depart_nom: 'Gare A', + gare_arrivee_nom: 'Gare B' + }; + const dessertes = [ + { gare_nom: 'Gare A', heure_depart: '10:00' }, + { gare_nom: 'Gare B', heure_arrivee: '11:00' } + ]; + + const { data, error } = await createSubstitution(payload, dessertes); + expect(error).toBeNull(); + expect(data).toBeDefined(); + expect(data.est_substitution).toBe(true); + expect(data.date_fin).toBeDefined(); + expect(data.gares_desservies).toHaveLength(2); + }); + }); +}); diff --git a/src/api/horairesSubstitutions.js b/src/api/horairesSubstitutions.js index 13909c6..f675a81 100644 --- a/src/api/horairesSubstitutions.js +++ b/src/api/horairesSubstitutions.js @@ -5,15 +5,29 @@ const isSupabaseConfigured = !!supabase; export async function getAllSubstitutions(regionId = null) { if (!isSupabaseConfigured) return { data: [], error: null }; try { + // Interroge en priorité la table unique consolidée 'horaires' let query = supabase - .from('horaires_substitutions') + .from('horaires') .select('*') + .eq('est_substitution', true) .order('created_at', { ascending: false }); if (regionId) query = query.eq('region_id', regionId); const { data, error } = await query; - return { data: data || [], error }; + if (error) { + // Repli sur l'ancienne table si la colonne est_substitution n'est pas encore migrée + let fallbackQuery = supabase + .from('horaires_substitutions') + .select('*') + .order('created_at', { ascending: false }); + + if (regionId) fallbackQuery = fallbackQuery.eq('region_id', regionId); + + const { data: fallbackData, error: fallbackError } = await fallbackQuery; + return { data: fallbackData || [], error: fallbackError }; + } + return { data: data || [], error: null }; } catch (err) { console.error('Erreur getAllSubstitutions:', err); return { data: [], error: err }; @@ -24,11 +38,22 @@ export async function getSubstitutionById(id) { if (!isSupabaseConfigured) return { data: null, error: { message: 'Supabase non configuré' } }; try { const { data, error } = await supabase - .from('horaires_substitutions') + .from('horaires') .select('*') .eq('id', id) + .eq('est_substitution', true) .single(); - if (error) return { data: null, error }; + + if (error) { + // Repli sur l'ancienne table + const { data: legacyData, error: legacyError } = await supabase + .from('horaires_substitutions') + .select('*') + .eq('id', id) + .single(); + if (legacyError) return { data: null, error: legacyError }; + return { data: legacyData, error: null }; + } return { data, error: null }; } catch (err) { console.error('Erreur getSubstitutionById:', err); @@ -39,11 +64,10 @@ export async function getSubstitutionById(id) { export async function createSubstitution(payload, garesDesservies = []) { if (!isSupabaseConfigured) { const fakeId = `local-${Date.now()}`; - // Ensure date_fin default to end of day for local mode const now = new Date(); const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).toISOString(); const payloadWithDefaults = { ...payload, date_fin: payload.date_fin || endOfDay }; - return { data: { id: fakeId, ...payloadWithDefaults, gares_desservies: garesDesservies }, error: null }; + return { data: { id: fakeId, ...payloadWithDefaults, est_substitution: true, gares_desservies: garesDesservies }, error: null }; } try { @@ -51,43 +75,75 @@ export async function createSubstitution(payload, garesDesservies = []) { let regionToUse = payload.region_id ?? null; if (!regionToUse && payload && payload.horaire_id) { try { - const { data: horaireData, error: horaireError } = await supabase + const { data: horaireData } = await supabase .from('horaires') .select('region_id, region') .eq('id', payload.horaire_id) .single(); - if (!horaireError && horaireData) regionToUse = horaireData.region_id || horaireData.region || null; + if (horaireData) regionToUse = horaireData.region_id || horaireData.region || null; } catch (e) { /* ignore */ } } // Définir date_fin à la fin de la journée si non fourni const now = new Date(); const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).toISOString(); - const payloadWithDefaults = { ...payload, region_id: regionToUse !== undefined ? regionToUse : payload.region_id, date_fin: payload.date_fin || endOfDay }; + const payloadWithDefaults = { + ...payload, + region_id: regionToUse !== undefined ? regionToUse : payload.region_id, + date_fin: payload.date_fin || endOfDay, + est_substitution: true + }; 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 + ordre: g.ordre ?? idx + 1, + quai: g.quai || null })); + // Insertion dans la table unique consolidée horaires const { data: horaire, error: horaireError } = await supabase - .from('horaires_substitutions') + .from('horaires') .insert([{ ...payloadWithDefaults, + type_train: payloadWithDefaults.type_train || 'Autocar', gares_desservies: garesDesserviesJson, - created_at: new Date(), - updated_at: new Date() + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() }]) .select('*') .single(); if (horaireError) { - console.error('Erreur création substitution:', horaireError); - return { data: null, error: horaireError }; + // Repli vers l'ancienne table si la colonne/table unique n'est pas disponible + const { data: legacyData, error: legacyError } = await supabase + .from('horaires_substitutions') + .insert([{ + ...payloadWithDefaults, + gares_desservies: garesDesserviesJson, + created_at: new Date(), + updated_at: new Date() + }]) + .select('*') + .single(); + + if (legacyError) { + console.error('Erreur création substitution:', legacyError); + return { data: null, error: legacyError }; + } + return { data: legacyData, error: null }; + } + + // Si associé à un train parent, marquer substitution_disponible = true + if (payload.horaire_id) { + try { + await supabase + .from('horaires') + .update({ substitution_disponible: true, updated_at: new Date().toISOString() }) + .eq('id', payload.horaire_id); + } catch (e) { /* ignore */ } } - // Recharger l'objet avec ses relations si nécessaire return { data: horaire, error: null }; } catch (err) { console.error('Exception createSubstitution:', err); @@ -104,24 +160,36 @@ export async function updateSubstitution(id, payload, garesDesservies = null) { gare_nom: g.gare_nom || '', heure_arrivee: g.heure_arrivee || null, heure_depart: g.heure_depart || null, - ordre: g.ordre ?? idx + 1 + ordre: g.ordre ?? idx + 1, + quai: g.quai || null })) : undefined; const updatePayload = { ...payload, - updated_at: new Date(), + updated_at: new Date().toISOString(), ...(garesDesserviesJson !== undefined ? { gares_desservies: garesDesserviesJson } : {}) }; const { data, error } = await supabase - .from('horaires_substitutions') + .from('horaires') .update(updatePayload) .eq('id', id) .select() .single(); - return { data, error }; + if (error) { + // Repli sur l'ancienne table + const { data: legacyData, error: legacyError } = await supabase + .from('horaires_substitutions') + .update(updatePayload) + .eq('id', id) + .select() + .single(); + return { data: legacyData, error: legacyError }; + } + + return { data, error: null }; } catch (err) { console.error('Erreur updateSubstitution:', err); return { data: null, error: err }; @@ -132,10 +200,19 @@ export async function deleteSubstitution(id) { if (!isSupabaseConfigured) return { data: null, error: new Error('Supabase non configuré') }; try { const { error } = await supabase - .from('horaires_substitutions') + .from('horaires') .delete() .eq('id', id); - return { data: null, error }; + + if (error) { + // Repli sur l'ancienne table + const { error: legacyError } = await supabase + .from('horaires_substitutions') + .delete() + .eq('id', id); + return { data: null, error: legacyError }; + } + return { data: null, error: null }; } catch (err) { console.error('Erreur deleteSubstitution:', err); return { data: null, error: err }; diff --git a/src/api/utilisateurs/index.js b/src/api/utilisateurs/index.js index 1aa5315..cb60798 100644 --- a/src/api/utilisateurs/index.js +++ b/src/api/utilisateurs/index.js @@ -18,14 +18,19 @@ export async function getAllUtilisateurs() { .order('created_at', { ascending: false }); if (error) { - console.error('Erreur getAllUtilisateurs:', error); - return { data: null, error }; + console.warn('getAllUtilisateurs DB warning:', error?.message); + // Fallback si la table utilisateurs n'est pas utilisée : renvoyer l'utilisateur connecté + const local = window.localStorage.getItem('app_user'); + const currentUser = local ? JSON.parse(local) : null; + return { data: currentUser ? [currentUser] : [], error: null }; } - return { data, error: null }; + return { data: data || [], error: null }; } catch (err) { - console.error('Exception getAllUtilisateurs:', err); - return { data: null, error: err }; + console.warn('Exception getAllUtilisateurs:', err); + const local = window.localStorage.getItem('app_user'); + const currentUser = local ? JSON.parse(local) : null; + return { data: currentUser ? [currentUser] : [], error: null }; } } @@ -63,19 +68,46 @@ export async function createUtilisateur(userData) { created_at: new Date().toISOString() }; - const { data, error } = await supabase - .from('utilisateurs') - .insert([payload]) - .select() - .single(); - - if (error) { - // Gestion spécifique des erreurs d'unicité d'email - console.error('Erreur createUtilisateur:', error); - return { data: null, error }; + // Tentative d'enregistrement dans Supabase Auth (GoTrue) si un mot de passe est fourni + try { + if (userData.password) { + await supabase.auth.signUp({ + email: payload.email, + password: userData.password, + options: { + data: { + nom: payload.nom, + prenom: payload.prenom, + name: `${payload.nom} ${payload.prenom}`.trim(), + role: payload.role, + permissions: payload.permissions, + region_admin: payload.region_admin, + region_id: payload.region_id, + region_nom: payload.region_nom, + photo_url: payload.photo_url, + actif: payload.actif + } + } + }); + } + } catch (authErr) { + console.warn('[createUtilisateur] Supabase Auth signUp warning:', authErr); } - return { data, error: null }; + // Sauvegarde DB si la table utilisateurs existe + try { + const { data, error } = await supabase + .from('utilisateurs') + .insert([payload]) + .select() + .single(); + + if (!error && data) { + return { data, error: null }; + } + } catch (_) {} + + return { data: { id: payload.email, ...payload }, error: null }; } catch (err) { console.error('Exception createUtilisateur:', err); return { data: null, error: err }; @@ -122,19 +154,47 @@ export async function updateUtilisateur(id, userData) { updated_at: new Date().toISOString() }; - const { data, error } = await supabase - .from('utilisateurs') - .update(payload) - .eq('id', id) - .select() - .single(); - - if (error) { - console.error('Erreur updateUtilisateur:', error); - return { data: null, error }; + // Synchronisation avec Supabase Auth (user_metadata) + try { + const { data: authUserData } = await supabase.auth.getUser(); + const authUser = authUserData?.user; + if (authUser && (authUser.id === id || authUser.email?.toLowerCase() === payload.email)) { + await supabase.auth.updateUser({ + ...(userData.password ? { password: userData.password } : {}), + data: { + nom: payload.nom, + prenom: payload.prenom, + name: `${payload.nom} ${payload.prenom}`.trim(), + role: payload.role, + permissions: payload.permissions || {}, + region_admin: payload.region_admin, + region_id: payload.region_id, + region_nom: payload.region_nom, + photo_url: payload.photo_url, + actif: payload.actif + } + }); + } + } catch (authErr) { + console.warn('[updateUtilisateur] Sync Supabase Auth metadata warning:', authErr); } - return { data, error: null }; + // Mise à jour de la table DB s'il y a lieu + let dbData = null; + try { + const { data, error } = await supabase + .from('utilisateurs') + .update(payload) + .eq('id', id) + .select() + .maybeSingle(); + + if (!error && data) { + dbData = data; + } + } catch (_) {} + + return { data: dbData || { id, ...payload }, error: null }; } catch (err) { console.error('Exception updateUtilisateur:', err); return { data: null, error: err }; diff --git a/src/components/HoraireModal/CirculationCalendar.jsx b/src/components/HoraireModal/CirculationCalendar.jsx index 9004f7c..e7b6032 100644 --- a/src/components/HoraireModal/CirculationCalendar.jsx +++ b/src/components/HoraireModal/CirculationCalendar.jsx @@ -237,14 +237,14 @@ export default function CirculationCalendar({ serviceAnnuel, selectedDays, onCha
Sélection rapide : - - - - - - - - + applyTemplate('lun-ven')}>Lun à Ven + applyTemplate('sam')}>Sam + applyTemplate('dim')}>Dim + applyTemplate('dim-feries')}>Dim & Fériés + applyTemplate('sauf-sam')}>Sauf Samedi + applyTemplate('sauf-dim')}>Sauf Dimanche + applyTemplate('tous')}>Tous + applyTemplate('aucun')}>Aucun
diff --git a/src/components/HoraireModal/HoraireModal.css b/src/components/HoraireModal/HoraireModal.css new file mode 100644 index 0000000..d981900 --- /dev/null +++ b/src/components/HoraireModal/HoraireModal.css @@ -0,0 +1,635 @@ +/* ========================================================================== + SNCF DESIGN SYSTEM - HORAIRE MODAL (CREATION & MODIFICATION DE SILLONS) + ========================================================================== */ + +.horaire-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.65); + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1050; + padding: 16px; + animation: modalFadeIn 0.2s ease-out; +} + +.horaire-modal-container { + background: #ffffff; + border-radius: 18px; + width: 100%; + max-width: 980px; + max-height: 92vh; + display: flex; + flex-direction: column; + box-shadow: 0 25px 60px -15px rgba(0, 25, 50, 0.45), 0 0 0 1px rgba(0, 136, 206, 0.15); + overflow: hidden; + position: relative; +} + +/* Header SNCF */ +.horaire-sncf-header { + background: linear-gradient(135deg, #001932 0%, #0d3d67 60%, #0088ce 140%); + color: #ffffff; + padding: 20px 26px 18px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-shrink: 0; +} + +.horaire-header-left { + display: flex; + flex-direction: column; + gap: 6px; +} + +.horaire-header-kicker { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #7dd3fc; + background: rgba(255, 255, 255, 0.12); + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.horaire-header-title-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.horaire-header-title { + margin: 0; + font-size: 1.45rem; + font-weight: 800; + color: #ffffff; + display: flex; + align-items: center; + gap: 10px; +} + +/* Summary Journey Bar */ +.horaire-journey-summary { + background: linear-gradient(180deg, #f0f7ff 0%, #e6f2fc 100%); + border-bottom: 1px solid #bfdbfe; + padding: 14px 26px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.horaire-journey-track { + display: flex; + align-items: center; + gap: 16px; + flex: 1; + min-width: 280px; +} + +.horaire-journey-node { + display: flex; + flex-direction: column; + gap: 2px; +} + +.horaire-journey-node-tag { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + display: flex; + align-items: center; + gap: 4px; +} + +.horaire-journey-station { + font-size: 14.5px; + font-weight: 700; + color: #0f172a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} + +.horaire-journey-time { + font-size: 13px; + font-weight: 800; + color: #0088ce; + background: rgba(0, 136, 206, 0.1); + padding: 1px 8px; + border-radius: 6px; + width: fit-content; +} + +.horaire-journey-line-segment { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + position: relative; + min-width: 80px; +} + +.horaire-journey-line-bar { + width: 100%; + height: 3px; + background: #94a3b8; + position: relative; + border-radius: 2px; +} + +.horaire-journey-duration-chip { + font-size: 11px; + font-weight: 700; + color: #0369a1; + background: #ffffff; + padding: 2px 8px; + border-radius: 12px; + border: 1px solid #bae6fd; + white-space: nowrap; +} + +.horaire-journey-meta-tags { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +/* WCS SNCF Tabs Container */ +.horaire-tabs-container { + background: #f8fafc; + border-bottom: 2px solid #e2e8f0; + padding: 0 20px; + flex-shrink: 0; + display: flex; + align-items: center; + overflow-x: auto; +} + +.horaire-wcs-tabs-bar { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + border-bottom: none; + padding: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.horaire-wcs-tabs-bar::-webkit-scrollbar { + display: none; +} + +.horaire-tab-btn.wcs-tab-header { + background: transparent; + border: none; + border-bottom: 3px solid transparent; + padding: 13px 18px; + font-size: 0.92rem; + font-weight: 700; + color: #64748b; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + white-space: nowrap; + margin-bottom: -2px; + position: relative; + font-family: inherit; + border-radius: 4px 4px 0 0; +} + +.horaire-tab-btn.wcs-tab-header:hover { + color: #0088ce; + background: rgba(0, 136, 206, 0.05); +} + +.horaire-tab-btn.wcs-tab-header.active { + color: #0088ce; + border-bottom-color: #0088ce; + background: #ffffff; +} + +.horaire-tab-btn.wcs-tab-header.active::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 3px; + background-color: #0088ce; + border-radius: 3px 3px 0 0; +} + +.horaire-tab-text { + font-size: 0.92rem; + font-weight: 700; +} + +/* Modal Body Scroll */ +.horaire-modal-content-scroll { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + background: #f8fafc; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Cards WCS */ +.horaire-modal-container wcs-card { + display: block; + background: #ffffff; + border-radius: 14px; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); +} + +.horaire-modal-container wcs-card-body { + display: block; + padding: 18px 20px; +} + +.horaire-section-card-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.92rem; + font-weight: 800; + color: #001932; + text-transform: uppercase; + letter-spacing: 0.05em; + padding-bottom: 12px; + margin-bottom: 14px; + border-bottom: 1px solid #f1f5f9; +} + +/* Form Fields & Grids */ +.horaire-grid-2cols { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 10px; +} + +.horaire-modal-container wcs-form-field { + width: 100%; + display: block; +} + +.horaire-modal-container wcs-input, +.horaire-modal-container wcs-select { + width: 100%; + display: block; +} + +/* Autocomplete Gare */ +.horaire-autocomplete-wrapper { + position: relative; + width: 100%; +} + +.horaire-autocomplete-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 10px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.12); + z-index: 100; + max-height: 220px; + overflow-y: auto; +} + +.horaire-autocomplete-item { + width: 100%; + text-align: left; + background: transparent; + border: none; + padding: 10px 14px; + font-size: 0.88rem; + font-weight: 600; + color: #1e293b; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: background 0.15s ease; +} + +.horaire-autocomplete-item:hover { + background: #eff6ff; + color: #0088ce; +} + +/* Arrêts intermédiaires WCS card */ +.horaire-stop-card { + display: block; +} + +.horaire-stop-card-inner { + display: flex; + align-items: flex-end; + gap: 12px; + width: 100%; +} + +.horaire-stop-reorder { + display: flex; + flex-direction: column; + gap: 2px; + align-self: center; +} + +.horaire-stop-fields { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 12px; + flex: 1; +} + +.horaire-stop-fields wcs-form-field { + margin-bottom: 0; +} + +/* Track Composition in Modal */ +.horaire-compo-track { + background: #0f172a; + border: 1px solid #1e293b; + border-radius: 12px; + padding: 18px 16px; + overflow-x: auto; + position: relative; + margin-top: 10px; +} + +.horaire-compo-rails { + position: absolute; + bottom: 12px; + left: 0; + right: 0; + height: 4px; + background: #475569; +} + +.horaire-compo-elements { + display: flex; + align-items: flex-end; + gap: 8px; + min-width: max-content; + position: relative; + z-index: 2; + padding-bottom: 8px; +} + +.horaire-compo-wagon { + display: flex; + flex-direction: column; + align-items: center; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 6px 10px; + cursor: grab; + user-select: none; + transition: transform 0.15s ease; +} + +.horaire-compo-wagon:hover { + background: rgba(255, 255, 255, 0.12); + transform: translateY(-2px); +} + +.horaire-compo-wagon.is-drag-over { + border-color: #0088ce; + background: rgba(0, 136, 206, 0.2); +} + +/* Materiels catalog grid */ +.horaire-materiels-catalog-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + max-height: 280px; + overflow-y: auto; + padding: 4px; +} + +.horaire-materiel-card { + border: 1.5px solid #e2e8f0; + border-radius: 10px; + padding: 10px; + background: #ffffff; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + text-align: center; + transition: all 0.2s ease; + position: relative; +} + +.horaire-materiel-card:hover:not(.is-occupied) { + border-color: #0088ce; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 136, 206, 0.15); +} + +.horaire-materiel-card.is-occupied { + opacity: 0.65; + background: #fef2f2; + border-color: #fecaca; + cursor: not-allowed; +} + +.horaire-materiel-card.is-in-compo { + border-color: #0088ce; + background: #eff6ff; +} + +/* Modal Footer */ +.horaire-modal-footer { + background: #f8fafc; + border-top: 1px solid #e2e8f0; + padding: 16px 26px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.horaire-footer-dots { + display: flex; + align-items: center; + gap: 8px; +} + +.horaire-footer-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: none; + background: #cbd5e1; + padding: 0; + cursor: pointer; + transition: all 0.2s ease; +} + +.horaire-footer-dot.is-active { + background: #0088ce; + transform: scale(1.25); +} + +.horaire-footer-buttons { + display: flex; + align-items: center; + gap: 12px; +} + +/* Mode sombre */ +body.dark-mode .horaire-modal-container, +[data-theme="dark"] .horaire-modal-container { + background: #0f172a; + color: #f8fafc; + box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.7); +} + +body.dark-mode .horaire-journey-summary, +[data-theme="dark"] .horaire-journey-summary { + background: #1e293b; + border-bottom-color: #334155; +} + +body.dark-mode .horaire-journey-station, +[data-theme="dark"] .horaire-journey-station { + color: #f8fafc; +} + +body.dark-mode .horaire-tabs-container, +[data-theme="dark"] .horaire-tabs-container { + background: #1e293b; + border-bottom-color: #334155; +} + +body.dark-mode .horaire-tab-btn.wcs-tab-header, +[data-theme="dark"] .horaire-tab-btn.wcs-tab-header { + color: #94a3b8; +} + +body.dark-mode .horaire-tab-btn.wcs-tab-header:hover, +[data-theme="dark"] .horaire-tab-btn.wcs-tab-header:hover { + color: #38bdf8; + background: rgba(56, 189, 248, 0.08); +} + +body.dark-mode .horaire-tab-btn.wcs-tab-header.active, +[data-theme="dark"] .horaire-tab-btn.wcs-tab-header.active { + color: #38bdf8; + border-bottom-color: #38bdf8; + background: #0f172a; +} + +body.dark-mode .horaire-tab-btn.wcs-tab-header.active::after, +[data-theme="dark"] .horaire-tab-btn.wcs-tab-header.active::after { + background-color: #38bdf8; +} + +body.dark-mode .horaire-modal-content-scroll, +[data-theme="dark"] .horaire-modal-content-scroll { + background: #0f172a; +} + +body.dark-mode .horaire-modal-container wcs-card, +[data-theme="dark"] .horaire-modal-container wcs-card { + background: #1e293b; + border-color: #334155; +} + +body.dark-mode .horaire-section-card-title, +[data-theme="dark"] .horaire-section-card-title { + color: #f8fafc; + border-bottom-color: #334155; +} + +body.dark-mode .horaire-autocomplete-dropdown, +[data-theme="dark"] .horaire-autocomplete-dropdown { + background: #1e293b; + border-color: #334155; +} + +body.dark-mode .horaire-autocomplete-item, +[data-theme="dark"] .horaire-autocomplete-item { + color: #f8fafc; +} + +body.dark-mode .horaire-autocomplete-item:hover, +[data-theme="dark"] .horaire-autocomplete-item:hover { + background: #334155; + color: #38bdf8; +} + +body.dark-mode .horaire-materiel-card, +[data-theme="dark"] .horaire-materiel-card { + background: #1e293b; + border-color: #334155; +} + +body.dark-mode .horaire-modal-footer, +[data-theme="dark"] .horaire-modal-footer { + background: #1e293b; + border-top-color: #334155; +} + +@keyframes modalFadeIn { + from { + opacity: 0; + transform: scale(0.97); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@media (max-width: 768px) { + .horaire-grid-2cols, + .horaire-stop-fields { + grid-template-columns: 1fr; + } + + .horaire-stop-card-inner { + flex-direction: column; + align-items: stretch; + } + + .horaire-journey-summary { + flex-direction: column; + align-items: stretch; + } +} diff --git a/src/components/HoraireModal/HoraireModal.jsx b/src/components/HoraireModal/HoraireModal.jsx index 6ec08f9..9e0fc8b 100644 --- a/src/components/HoraireModal/HoraireModal.jsx +++ b/src/components/HoraireModal/HoraireModal.jsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { createHoraire, updateHoraire } from '../../api/horaires'; import CirculationCalendar from './CirculationCalendar'; +import './HoraireModal.css'; export default function HoraireModal({ initialData, @@ -22,17 +23,13 @@ export default function HoraireModal({ const [estSubstitution, setEstSubstitution] = useState(false); const [motifSubstitution, setMotifSubstitution] = useState(''); - const clearDuplicatedHoraire = () => {}; - const setIsDuplicating = () => {}; - const [formError, setFormError] = useState(''); const [activeTab, setActiveTab] = useState('general'); - const [gareSuggestionsOpenFor, setGareSuggestionsOpenFor] = useState(null); const [editingItem, setEditingItem] = useState(null); const [periodesCirculationPerso, setPeriodesCirculationPerso] = useState([]); const [garesList, setGaresList] = useState(allGares || []); -// Champs formulaire + // Champs formulaire const [numeroTrain, setNumeroTrain] = useState(''); const [typeTrainId, setTypeTrainId] = useState(''); const [typeTrainNom, setTypeTrainNom] = useState(''); @@ -40,7 +37,7 @@ export default function HoraireModal({ const [serviceAnnuelId, setServiceAnnuelId] = useState(''); const [serviceAnnuelNom, setServiceAnnuelNom] = useState(''); - // gare départ / arrivée stockent id + nom pour affichage + // Gares départ / arrivée const [gareDepartId, setGareDepartId] = useState(''); const [gareDepartNom, setGareDepartNom] = useState(''); const [heureDepart, setHeureDepart] = useState(''); @@ -51,7 +48,7 @@ export default function HoraireModal({ const [garesDesservies, setGaresDesservies] = useState([]); - // jours + // Jours const [circuleLundi, setCirculeLundi] = useState(true); const [circuleMardi, setCirculeMardi] = useState(true); const [circuleMercredi, setCirculeMercredi] = useState(true); @@ -62,22 +59,24 @@ export default function HoraireModal({ const [circuleJoursFeries, setCirculeJoursFeries] = useState(false); const [circuleDimanchesFeries, setCirculeDimanchesFeries] = useState(false); const [joursNonCirculation, setJoursNonCirculation] = useState([]); - // Périodes de circulation/non-circulation : [{debut: 'YYYY-MM-DD', fin: 'YYYY-MM-DD', circule: true/false}] const [periodesCirculation, setPeriodesCirculation] = useState([]); const [selectedDatesSet, setSelectedDatesSet] = useState(new Set()); - // matériel + // Matériel const [materielRoulantId, setMaterielRoulantId] = useState(null); - // composition de train : [{id: uid_local, materiel_id, materiel_nom, materiel_nom_technique, materiel_image_url, position}] const [compositionTrain, setCompositionTrain] = useState([]); - // drag & drop composition const [dragOverIndex, setDragOverIndex] = useState(null); - // Substitution - + const modalRef = useRef(null); + const tabsRef = useRef(null); const formatDate = (d) => d ? new Date(d).toLocaleDateString('fr-FR') : ''; + // Liste des gares triées alphabétiquement + const sortedGares = useMemo(() => { + return [...(garesList || [])].sort((a, b) => (a.nom || '').localeCompare(b.nom || '', 'fr')); + }, [garesList]); + const resetForm = useCallback(() => { setNumeroTrain(''); setTypeTrainId(''); setTypeTrainNom(''); @@ -96,8 +95,6 @@ export default function HoraireModal({ setEstSubstitution(false); setMotifSubstitution(''); setServiceAnnuelId(''); setServiceAnnuelNom(''); setFormError(''); setActiveTab('general'); - // NB: ne pas modifier l'état `showModal` ici : resetForm ne doit pas ouvrir/fermer la modale - setIsDuplicating(false); }, []); useEffect(() => { @@ -108,14 +105,32 @@ export default function HoraireModal({ if (initialData) { setEditingItem(initialData); setNumeroTrain(initialData.numero_train || ''); - const ttNom = (typeof initialData.type_train === 'object' && initialData.type_train !== null) ? (initialData.type_train.nom || '') : (initialData.type_train || ''); + const ttNom = (typeof initialData.type_train === 'object' && initialData.type_train !== null) + ? (initialData.type_train.nom || '') + : (initialData.type_train || ''); const ttId = initialData.type_train_id || (typesTrainsList.find(t => t.nom === ttNom)?.id || ''); - setTypeTrainId(String(ttId)); + setTypeTrainId(String(ttId || '')); setTypeTrainNom(ttNom); setLigne(initialData.ligne_id || initialData.ligne || ''); - setGareDepartId(initialData.gare_depart_id || ''); setGareDepartNom(initialData.gare_depart_nom || ''); + + let dId = initialData.gare_depart_id ? String(initialData.gare_depart_id) : ''; + const dNom = initialData.gare_depart_nom || ''; + if (!dId && dNom) { + const found = (allGares || []).find(g => (g.nom || '').trim().toLowerCase() === dNom.trim().toLowerCase()); + if (found) dId = String(found.id); + } + setGareDepartId(dId); + setGareDepartNom(dNom); setHeureDepart(initialData.heure_depart || ''); - setGareArriveeId(initialData.gare_arrivee_id || ''); setGareArriveeNom(initialData.gare_arrivee_nom || ''); + + let aId = initialData.gare_arrivee_id ? String(initialData.gare_arrivee_id) : ''; + const aNom = initialData.gare_arrivee_nom || ''; + if (!aId && aNom) { + const found = (allGares || []).find(g => (g.nom || '').trim().toLowerCase() === aNom.trim().toLowerCase()); + if (found) aId = String(found.id); + } + setGareArriveeId(aId); + setGareArriveeNom(aNom); setHeureArrivee(initialData.heure_arrivee || ''); let inter = []; @@ -124,29 +139,27 @@ export default function HoraireModal({ if (sorted.some(g => g.is_depart !== undefined || g.is_arrivee !== undefined)) { inter = sorted.filter(g => !g.is_depart && !g.is_arrivee); } else { - const dNom = (initialData.gare_depart_nom || '').trim().toLowerCase(); - const dId = initialData.gare_depart_id ? String(initialData.gare_depart_id) : null; - const aNom = (initialData.gare_arrivee_nom || '').trim().toLowerCase(); - const aId = initialData.gare_arrivee_id ? String(initialData.gare_arrivee_id) : null; + const lowerDNom = (initialData.gare_depart_nom || '').trim().toLowerCase(); + const lowerDId = initialData.gare_depart_id ? String(initialData.gare_depart_id) : null; + const lowerANom = (initialData.gare_arrivee_nom || '').trim().toLowerCase(); + const lowerAId = initialData.gare_arrivee_id ? String(initialData.gare_arrivee_id) : null; let temp = [...sorted]; - // Strip all leading departure stops while (temp.length > 0) { const f = temp[0]; const fNom = (f.gare_nom || '').trim().toLowerCase(); const fId = f.gare_id ? String(f.gare_id) : null; - if ((dId && fId === dId) || (dNom && fNom === dNom)) { + if ((lowerDId && fId === lowerDId) || (lowerDNom && fNom === lowerDNom)) { temp.shift(); } else { break; } } - // Strip all trailing arrival stops while (temp.length > 0) { const l = temp[temp.length - 1]; const lNom = (l.gare_nom || '').trim().toLowerCase(); const lId = l.gare_id ? String(l.gare_id) : null; - if ((aId && lId === aId) || (aNom && lNom === aNom)) { + if ((lowerAId && lId === lowerAId) || (lowerANom && lNom === lowerANom)) { temp.pop(); } else { break; @@ -155,6 +168,17 @@ export default function HoraireModal({ inter = temp; } } + + inter = inter.map(item => { + let gId = item.gare_id ? String(item.gare_id) : ''; + const gNom = item.gare_nom || ''; + if (!gId && gNom) { + const match = (allGares || []).find(g => (g.nom || '').trim().toLowerCase() === gNom.trim().toLowerCase()); + if (match) gId = String(match.id); + } + return { ...item, gare_id: gId, gare_nom: gNom }; + }); + setGaresDesservies(inter); setCirculeLundi(initialData.circule_lundi ?? true); @@ -188,59 +212,55 @@ export default function HoraireModal({ setDragOverIndex(null); setEstSubstitution(initialData.est_substitution ?? false); setMotifSubstitution(initialData.motif_substitution || ''); - setServiceAnnuelId(initialData.service_annuel_id || (initialData.service_annuel? initialData.service_annuel.id : '') || ''); + setServiceAnnuelId(initialData.service_annuel_id || (initialData.service_annuel ? initialData.service_annuel.id : '') || ''); setServiceAnnuelNom(initialData.service_annuel?.nom || initialData.service_annuel_nom || ''); setPeriodesCirculation(initialData.periodes_circulation || []); } else { resetForm(); } - }, [initialData, typesTrainsList, resetForm]); + }, [initialData, typesTrainsList, allGares, resetForm]); const closeModal = () => { resetForm(); onClose(); }; -const getMatchingGares = useCallback((query) => { - const value = String(query || '').trim().toLowerCase(); - if (!value || value.length < 2) return []; - return (garesList || []) - .filter(g => String(g?.nom || '').toLowerCase().includes(value)) - .slice(0, 8); + const handleGareDepartChange = useCallback((val) => { + const stringVal = val ? String(val) : ''; + setGareDepartId(stringVal); + const sel = (garesList || []).find(g => String(g.id) === stringVal); + setGareDepartNom(sel ? (sel.nom || '') : ''); }, [garesList]); - - -const findGareFromSearch = useCallback((query) => { - const value = String(query || '').trim(); - if (!value) return null; - const lower = value.toLowerCase(); - return garesList.find(g => String(g.id) === value || String(g.nom || '').toLowerCase() === lower) || null; + const handleGareArriveeChange = useCallback((val) => { + const stringVal = val ? String(val) : ''; + setGareArriveeId(stringVal); + const sel = (garesList || []).find(g => String(g.id) === stringVal); + setGareArriveeNom(sel ? (sel.nom || '') : ''); }, [garesList]); -const applyMainGareSelection = useCallback((value, setId, setNom) => { - const selected = findGareFromSearch(value); - if (selected) { - setId(String(selected.id)); - setNom(selected.nom || ''); - return; - } - setId(''); - setNom(value || ''); - }, [findGareFromSearch]); + const handleTypeTrainChange = useCallback((val) => { + const stringVal = val ? String(val) : ''; + setTypeTrainId(stringVal); + const sel = (typesTrainsList || []).find(t => String(t.id) === stringVal); + setTypeTrainNom(sel ? (sel.nom || '') : ''); + }, [typesTrainsList]); -const addGareDesservie = useCallback(() => { - setGaresDesservies(prev => ([...prev, { id: `new-${Date.now()}`, gare_id: '', gare_nom: '', heure_arrivee: '', heure_depart: '', ordre: prev.length + 1 }])); + const addGareDesservie = useCallback(() => { + setGaresDesservies(prev => ([ + ...prev, + { id: `new-${Date.now()}-${Math.random()}`, gare_id: '', gare_nom: '', heure_arrivee: '', heure_depart: '', ordre: prev.length + 1 } + ])); }, []); -const removeGareDesservie = useCallback((index) => { + const removeGareDesservie = useCallback((index) => { setGaresDesservies(prev => { const filtered = prev.filter((_, i) => i !== index); return filtered.map((g, i) => ({ ...g, ordre: i + 1 })); }); }, []); -const moveGareUp = useCallback((index) => { + const moveGareUp = useCallback((index) => { if (index === 0) return; setGaresDesservies(prev => { const copy = [...prev]; @@ -250,7 +270,7 @@ const moveGareUp = useCallback((index) => { }); }, []); -const moveGareDown = useCallback((index) => { + const moveGareDown = useCallback((index) => { setGaresDesservies(prev => { if (index === prev.length - 1) return prev; const copy = [...prev]; @@ -260,7 +280,7 @@ const moveGareDown = useCallback((index) => { }); }, []); -const updateGareDesservieField = useCallback((index, field, value) => { + const updateGareDesservieField = useCallback((index, field, value) => { setGaresDesservies(prev => { const copy = [...prev]; copy[index] = { ...copy[index], [field]: value }; @@ -268,32 +288,184 @@ const updateGareDesservieField = useCallback((index, field, value) => { }); }, []); -const handleCalendarChange = useCallback((newSet, templateName) => { - setSelectedDatesSet(newSet); - if (templateName && templateName !== 'custom') { - setCirculeLundi(templateName === 'lun-ven' || templateName === 'tous' || templateName === 'sauf-sam' || templateName === 'sauf-dim'); - setCirculeMardi(templateName === 'lun-ven' || templateName === 'tous' || templateName === 'sauf-sam' || templateName === 'sauf-dim'); - setCirculeMercredi(templateName === 'lun-ven' || templateName === 'tous' || templateName === 'sauf-sam' || templateName === 'sauf-dim'); - setCirculeJeudi(templateName === 'lun-ven' || templateName === 'tous' || templateName === 'sauf-sam' || templateName === 'sauf-dim'); - setCirculeVendredi(templateName === 'lun-ven' || templateName === 'tous' || templateName === 'sauf-sam' || templateName === 'sauf-dim'); - setCirculeSamedi(templateName === 'sam' || templateName === 'tous' || templateName === 'sauf-dim'); - setCirculeDimanche(templateName === 'dim' || templateName === 'dim-feries' || templateName === 'tous' || templateName === 'sauf-sam'); - setCirculeJoursFeries(templateName === 'dim-feries' || templateName === 'tous'); - } -}, []); + const handleGareDesservieSelectChange = useCallback((index, val) => { + const stringVal = val ? String(val) : ''; + const sel = (garesList || []).find(gx => String(gx.id) === stringVal); + setGaresDesservies(prev => { + const copy = [...prev]; + copy[index] = { + ...copy[index], + gare_id: stringVal, + gare_nom: sel ? (sel.nom || '') : '' + }; + return copy; + }); + }, [garesList]); -const handleSave = async () => { + const handleCalendarChange = useCallback((newSet, templateName) => { + setSelectedDatesSet(newSet); + if (templateName && templateName !== 'custom') { + setCirculeLundi(templateName === 'lun-ven' || templateName === 'tous' || templateName === 'sauf-sam' || templateName === 'sauf-dim'); + setCirculeMardi(templateName === 'lun-ven' || templateName === 'tous' || templateName === 'sauf-sam' || templateName === 'sauf-dim'); + setCirculeMercredi(templateName === 'lun-ven' || templateName === 'tous' || templateName === 'sauf-sam' || templateName === 'sauf-dim'); + setCirculeJeudi(templateName === 'lun-ven' || templateName === 'tous' || templateName === 'sauf-sam' || templateName === 'sauf-dim'); + setCirculeVendredi(templateName === 'lun-ven' || templateName === 'tous' || templateName === 'sauf-sam' || templateName === 'sauf-dim'); + setCirculeSamedi(templateName === 'sam' || templateName === 'tous' || templateName === 'sauf-dim'); + setCirculeDimanche(templateName === 'dim' || templateName === 'dim-feries' || templateName === 'tous' || templateName === 'sauf-sam'); + setCirculeJoursFeries(templateName === 'dim-feries' || templateName === 'tous'); + } + }, []); + + // Configuration du filtre substring sur les wcs-select autocomplete + useEffect(() => { + if (!modalRef.current) return; + const root = modalRef.current; + const selects = root.querySelectorAll('wcs-select[autocomplete]'); + selects.forEach(sel => { + sel.filterFn = (optionEl, filter) => { + const text = (optionEl.textContent || '').toLowerCase(); + const q = (filter || '').trim().toLowerCase(); + return text.includes(q); + }; + }); + }); + + // Synchronisation wcs-tabs + useEffect(() => { + if (tabsRef.current) { + tabsRef.current.selectedKey = activeTab; + try { + tabsRef.current.setAttribute('selected-key', activeTab); + } catch (e) {} + } + }, [activeTab]); + + // Listener conteneur pour synchronisation bidirectionnelle WCS -> React + useEffect(() => { + const root = modalRef.current; + if (!root) return; + + const handleEvent = (e) => { + if (e.type === 'tabChange') { + const key = e.detail?.selectedKey || e.detail?.tabName; + if (key && key !== activeTab) { + setActiveTab(key); + } + return; + } + + const target = e.target; + const field = target?.dataset?.field; + if (!field) return; + + const val = (e.detail && typeof e.detail.value !== 'undefined' ? e.detail.value : undefined) + ?? (e.detail && typeof e.detail.checked !== 'undefined' ? e.detail.checked : undefined) + ?? (target && typeof target.value !== 'undefined' ? target.value : undefined) + ?? (target && typeof target.checked !== 'undefined' ? target.checked : undefined); + + if (field === 'numeroTrain' && typeof val === 'string') { + setNumeroTrain(val); + } else if (field === 'typeTrainId') { + handleTypeTrainChange(val); + } else if (field === 'ligne') { + setLigne(val ? String(val) : ''); + } else if (field === 'serviceAnnuelId') { + const sel = servicesAnnuels.find(s => String(s.id) === String(val)); + setServiceAnnuelId(val ? String(val) : ''); + setServiceAnnuelNom(sel ? sel.nom : ''); + } else if (field === 'gareDepartId') { + handleGareDepartChange(val); + } else if (field === 'heureDepart') { + setHeureDepart(val || ''); + } else if (field === 'gareArriveeId') { + handleGareArriveeChange(val); + } else if (field === 'heureArrivee') { + setHeureArrivee(val || ''); + } else if (field === 'estSubstitution') { + setEstSubstitution(!!val); + } else if (field === 'motifSubstitution') { + setMotifSubstitution(val || ''); + } + }; + + root.addEventListener('tabChange', handleEvent); + root.addEventListener('wcsChange', handleEvent); + root.addEventListener('wcsInput', handleEvent); + root.addEventListener('input', handleEvent); + root.addEventListener('change', handleEvent); + + return () => { + root.removeEventListener('tabChange', handleEvent); + root.removeEventListener('wcsChange', handleEvent); + root.removeEventListener('wcsInput', handleEvent); + root.removeEventListener('input', handleEvent); + root.removeEventListener('change', handleEvent); + }; + }, [activeTab, servicesAnnuels, handleTypeTrainChange, handleGareDepartChange, handleGareArriveeChange]); + + // Synchronisation ultime depuis les nœuds DOM avant enregistrement + const syncFormFromDOM = useCallback(() => { + if (!modalRef.current) return; + const root = modalRef.current; + + const numEl = root.querySelector('[data-field="numeroTrain"]'); + if (numEl && typeof numEl.value !== 'undefined' && numEl.value !== numeroTrain) { + setNumeroTrain(numEl.value); + } + const hDepEl = root.querySelector('[data-field="heureDepart"]'); + if (hDepEl && typeof hDepEl.value !== 'undefined' && hDepEl.value !== heureDepart) { + setHeureDepart(hDepEl.value); + } + const hArrEl = root.querySelector('[data-field="heureArrivee"]'); + if (hArrEl && typeof hArrEl.value !== 'undefined' && hArrEl.value !== heureArrivee) { + setHeureArrivee(hArrEl.value); + } + const motifEl = root.querySelector('[data-field="motifSubstitution"]'); + if (motifEl && typeof motifEl.value !== 'undefined' && motifEl.value !== motifSubstitution) { + setMotifSubstitution(motifEl.value); + } + const depSelect = root.querySelector('[data-field="gareDepartId"]'); + if (depSelect && depSelect.value && String(depSelect.value) !== String(gareDepartId)) { + handleGareDepartChange(depSelect.value); + } + const arrSelect = root.querySelector('[data-field="gareArriveeId"]'); + if (arrSelect && arrSelect.value && String(arrSelect.value) !== String(gareArriveeId)) { + handleGareArriveeChange(arrSelect.value); + } + const typeSelect = root.querySelector('[data-field="typeTrainId"]'); + if (typeSelect && typeSelect.value && String(typeSelect.value) !== String(typeTrainId)) { + handleTypeTrainChange(typeSelect.value); + } + }, [numeroTrain, heureDepart, heureArrivee, motifSubstitution, gareDepartId, gareArriveeId, typeTrainId, handleGareDepartChange, handleGareArriveeChange, handleTypeTrainChange]); + + // Calcul du temps de trajet estimé + const travelDurationStr = useMemo(() => { + if (!heureDepart || !heureArrivee) return null; + const [hD, mD] = heureDepart.split(':').map(Number); + const [hA, mA] = heureArrivee.split(':').map(Number); + if (isNaN(hD) || isNaN(mD) || isNaN(hA) || isNaN(mA)) return null; + let diffMinutes = (hA * 60 + mA) - (hD * 60 + mD); + if (diffMinutes < 0) diffMinutes += 24 * 60; + const hours = Math.floor(diffMinutes / 60); + const mins = diffMinutes % 60; + return `${hours > 0 ? `${hours}h ` : ''}${mins > 0 ? `${mins}min` : ''}` || '0min'; + }, [heureDepart, heureArrivee]); + + const handleSave = async () => { if (!editingItem && !canCreateHoraires) return setFormError('Vous n\'avez pas les droits pour créer un horaire.'); if (editingItem && !canUpdateHoraires) return setFormError('Vous n\'avez pas les droits pour modifier un horaire.'); setFormError(''); - if (!numeroTrain) return setFormError('Le numéro de train est requis.'); + + syncFormFromDOM(); + + if (!numeroTrain || !numeroTrain.trim()) return setFormError('Le numéro de train est requis.'); if (!gareDepartId || !gareArriveeId) return setFormError('Les gares de départ et d\'arrivée sont requises.'); if (!heureDepart || !heureArrivee) return setFormError('Les heures de départ et d\'arrivée sont requises.'); setSaving(true); const selLigne = lignes.find(ll => String(ll.id) === String(ligne)); const payload = { - numero_train: numeroTrain, + numero_train: numeroTrain.trim(), type_train_id: typeTrainId || null, type_train: typeTrainNom, ligne_id: ligne || null, @@ -314,18 +486,13 @@ const handleSave = async () => { circule_jours_feries: circuleJoursFeries, circule_dimanches_feries: circuleDimanchesFeries, jours_non_circulation: joursNonCirculation, - // Fusionne jours uniques et périodes dans jours_personnalises jours_personnalises: [ - // On récupère tous les jours depuis le composant CirculationCalendar ...Array.from(selectedDatesSet).map(date => ({ date, circule: true })), - - // Périodes générales (format {debut, fin, circule}) ...( Array.isArray(periodesCirculation) ? periodesCirculation.filter(p => p && p.debut && p.fin && p.debut <= p.fin).map(p => ({ debut: p.debut, fin: p.fin, circule: p.circule })) : [] ), - // Périodes personnalisées (format {debut, fin, circule}) ...( Array.isArray(periodesCirculationPerso) ? periodesCirculationPerso.filter(p => p && p.debut && p.fin && p.debut <= p.fin).map(p => ({ debut: p.debut, fin: p.fin, circule: p.circule })) @@ -347,7 +514,6 @@ const handleSave = async () => { actif: true }; - // ── Mode import Excel : bypasser l'API Supabase ── if (typeof onSaveOverride === 'function') { onSaveOverride(payload, garesDesservies); setSaving(false); @@ -357,11 +523,8 @@ const handleSave = async () => { try { let res; if (editingItem && editingItem.id) { - // Update existing res = await updateHoraire(editingItem.id, payload, garesDesservies); } else { - // Create new - // `garesClean` n'existe pas — utiliser `garesDesservies` qui contient la liste des gares saisies res = await createHoraire(payload, garesDesservies, currentUser); } @@ -370,457 +533,826 @@ const handleSave = async () => { setSaving(false); return; } - - // Si c'était une duplication, nettoyer le LocalStorage - if (isDuplicating) { - clearDuplicatedHoraire(); - } - setSaving(false); - onSaveSuccess(res); onClose(); + if (onSaveSuccess) onSaveSuccess(res); + onClose(); } catch (err) { console.error(err); setFormError('Erreur inattendue lors de la sauvegarde'); setSaving(false); } - } + }; - const TABS = ['general', 'gares', 'jours', 'materiel', 'substitution']; - const TAB_LABELS = { general: 'Général', gares: 'Gares desservies', jours: 'Calendrier de circulation', materiel: 'Matériel roulant', substitution: 'Substitution' }; + const TABS = [ + { key: 'general', label: 'Général', icon: 'tune', badge: null }, + { key: 'gares', label: 'Gares desservies', icon: 'share_location', badge: garesDesservies.length }, + { key: 'jours', label: 'Calendrier', icon: 'calendar_month', badge: selectedDatesSet.size ? `${selectedDatesSet.size}j` : null }, + { key: 'materiel', label: 'Matériel roulant', icon: 'train', badge: compositionTrain.length || null }, + { key: 'substitution', label: 'Substitution', icon: 'directions_bus', badge: estSubstitution ? 'Actif' : null } + ]; - return ( -
-
e.stopPropagation()} - style={{ maxWidth: 940, display: 'flex', flexDirection: 'column', maxHeight: '92vh' }} - > -
-
-
Gestion du sillon
-

- {isDuplicating ? 'Dupliquer l\'horaire' : editingItem ? 'Modifier l\'horaire' : 'Nouvel horaire'} -

-
- -
- -
-
- Départ - {gareDepartNom || 'Gare inconnue'} - {heureDepart || '--:--'} -
-
→
-
- Arrivée - {gareArriveeNom || 'Gare inconnue'} - {heureArrivee || '--:--'} -
-
- -
- {TABS.map(t => ( - - ))} -
- -
- {formError && ( -
- ⚠️ {formError} -
+ return ( +
+
e.stopPropagation()} ref={modalRef}> + + {/* 1. HEADER SNCF DESIGN SYSTEM */} +
+
+ + + Sillons & Référentiel Horaire + +
+

+ + {isDuplicating + ? 'Dupliquer le sillon' + : editingItem + ? `Modifier le train ${numeroTrain || ''}` + : 'Nouveau sillon ferroviaire' + } +

+ {typeTrainNom && ( + + {typeTrainNom} + )} - - {activeTab === 'general' && ( -
-
-
Identification
-
-
- - setNumeroTrain(e.target.value)} placeholder="Ex: TER 12345" /> -
-
- - - {typeTrainNom && ( -
✓ {typeTrainNom}
- )} -
-
- -
-
- - -
-
- - -
-
-
- -
-
Trajet
-
-
- -
- setGareSuggestionsOpenFor('depart')} - onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === 'depart' ? null : prev), 120)} - onChange={e => applyMainGareSelection(e.target.value, setGareDepartId, setGareDepartNom)} - placeholder="Rechercher une gare..." - /> - {gareSuggestionsOpenFor === 'depart' && getMatchingGares(gareDepartNom).length > 0 && ( -
- {getMatchingGares(gareDepartNom).map(g => ( - - ))} -
- )} -
-
-
- - setHeureDepart(e.target.value)} /> -
-
- -
-
- -
- setGareSuggestionsOpenFor('arrivee')} - onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === 'arrivee' ? null : prev), 120)} - onChange={e => applyMainGareSelection(e.target.value, setGareArriveeId, setGareArriveeNom)} - placeholder="Rechercher une gare..." - /> - {gareSuggestionsOpenFor === 'arrivee' && getMatchingGares(gareArriveeNom).length > 0 && ( -
- {getMatchingGares(gareArriveeNom).map(g => ( - - ))} -
- )} -
-
-
- - setHeureArrivee(e.target.value)} /> -
-
-
-
+ {estSubstitution && ( + + 🚌 Substitution Bus + )} - - {/* ══ TAB GARES DESSERVIES ══ */} - {activeTab === 'gares' && ( -
-

- Ajoutez les gares intermédiaires desservies, dans l'ordre du parcours. -

- {garesDesservies.length === 0 ? ( -
-
🚉
-

Aucune gare desservie — trajet direct

-
- ) : garesDesservies.map((g, idx) => ( -
-
- - -
-
-
- -
- setGareSuggestionsOpenFor(`desservie-${idx}`)} - onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === `desservie-${idx}` ? null : prev), 120)} - onChange={e => { - const value = e.target.value; - const sel = findGareFromSearch(value); - updateGareDesservieField(idx, 'gare_nom', value); - updateGareDesservieField(idx, 'gare_id', sel ? String(sel.id) : ''); - }} - placeholder="Rechercher une gare..." - /> - {gareSuggestionsOpenFor === `desservie-${idx}` && getMatchingGares(g.gare_nom).length > 0 && ( -
- {getMatchingGares(g.gare_nom).map(x => ( - - ))} -
- )} -
-
-
- - updateGareDesservieField(idx, 'heure_arrivee', e.target.value)} /> -
-
- - updateGareDesservieField(idx, 'heure_depart', e.target.value)} /> -
- -
-
- ))} - -
- )} - - {/* ══ TAB JOURS ══ */} - {activeTab === 'jours' && ( -
-

- Sélectionnez les jours de circulation. Les jours sélectionnés seront sauvegardés individuellement dans ce service annuel. -

-
- String(s.id) === String(serviceAnnuelId))} - selectedDays={selectedDatesSet} - onChange={handleCalendarChange} - /> -
-
- )} - - {/* ══ TAB MATÉRIEL ══ */} - {activeTab === 'materiel' && ( -
- {/* Composition */} -
-
- - Composition du train - {compositionTrain.length > 0 && ({compositionTrain.length} élément{compositionTrain.length > 1 ? 's' : ''})} - - {compositionTrain.length > 0 && ( - - )} -
- {compositionTrain.length === 0 ? ( -
-
🚂
-
Aucun matériel — cliquez ci-dessous pour ajouter
-
- ) : ( -
-
-
-
- {compositionTrain.map((item, idx) => ( -
{ e.dataTransfer.setData('text/plain', String(idx)); e.dataTransfer.effectAllowed = 'move'; }} - onDragOver={e => { e.preventDefault(); setDragOverIndex(idx); }} - onDragLeave={() => setDragOverIndex(null)} - onDrop={e => { - e.preventDefault(); - const from = parseInt(e.dataTransfer.getData('text/plain'), 10); - if (from === idx) { setDragOverIndex(null); return; } - setCompositionTrain(prev => { - const copy = [...prev]; const [moved] = copy.splice(from, 1); copy.splice(idx, 0, moved); - return copy.map((it, i) => ({ ...it, position: i + 1 })); - }); - setDragOverIndex(null); - }} - onDragEnd={() => setDragOverIndex(null)} - style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'grab', outline: dragOverIndex === idx ? '2px solid #0088ce' : 'none', borderRadius: 4, userSelect: 'none', position: 'relative', zIndex: 1 }} - > -
- {item.materiel_image_url - ? {item.materiel_nom - :
- -
- } -
-
{idx + 1}
- -
- ))} -
-
- {compositionTrain.map(item => ( -
- {item.materiel_nom || item.materiel_nom_technique || '—'} -
- ))} -
-
- )} -
- - {/* ── Liste des matériels disponibles ── */} -
- - Matériels disponibles — cliquez pour ajouter - - {materiels.length === 0 - ?
Aucun matériel
- : ( -
- {materiels.map(m => { - const inCompo = compositionTrain.filter(c => c.materiel_id === m.id).length; - const occupied = (() => { - if (!heureDepart || !heureArrivee) return false; - return horaires.some(h => { - if (!h?.materiel_roulant_id || String(h.materiel_roulant_id) !== String(m.id)) return false; - if (editingItem && String(h.id) === String(editingItem.id)) return false; - const hD = h.heure_depart || ''; const hA = h.heure_arrivee || ''; - return hD && hA && heureDepart < hA && heureArrivee > hD; - }); - })(); - return ( -
{ - if (occupied) return; - setCompositionTrain(p => [...p, { id: `comp-${Date.now()}-${Math.random()}`, materiel_id: m.id, materiel_nom: m.nom || '', materiel_nom_technique: m.nom_technique || '', materiel_image_url: m.image_url || null, position: p.length + 1 }]); - setMaterielRoulantId(m.id); - }} style={{ - border: inCompo > 0 ? '2px solid #0088ce' : (occupied ? '2px solid #ef4444' : '1px solid #e5e7eb'), - borderRadius: 8, padding: '10px 8px', cursor: occupied ? 'not-allowed' : 'pointer', - background: occupied ? '#fff5f5' : (inCompo > 0 ? '#eff6ff' : '#fff'), position: 'relative', opacity: occupied ? 0.7 : 1 - }}> - {occupied && OCCUPÉ} - {inCompo > 0 && !occupied && {inCompo}} -
- {m.image_url ? {m.nom - : } -
-
{m.nom || m.nom_technique}
- {m.nom_technique && m.nom &&
{m.nom_technique}
} -
{occupied ? 'Indisponible' : '+ Ajouter'}
-
- ); - })} -
- ) - } -
-
- )} - - {/* ══ TAB SUBSTITUTION ══ */} - {activeTab === 'substitution' && ( -
-
- - {estSubstitution && ( -
- - setMotifSubstitution(e.target.value)} placeholder="Ex: Travaux sur la ligne…" /> -
- )} -
-
- )} -
- - {/* ── Footer ── */} -
-
- {['general', 'gares', 'jours', 'materiel', 'substitution'].map(t => ( -
-
- - -
+ + +
- ); - + + {/* 2. BARRE RÉSUMÉ DU TRAJET */} +
+
+ {/* Gare Départ */} +
+
+ + Départ +
+ + {gareDepartNom || 'Gare non sélectionnée'} + + + {heureDepart || '--:--'} + +
+ + {/* Segment intermédiaire */} +
+ {travelDurationStr && ( + + ⏱️ {travelDurationStr} + + )} +
+
+ {garesDesservies.length} arrêt(s) interm. +
+
+ + {/* Gare Arrivée */} +
+
+ Arrivée + +
+ + {gareArriveeNom || 'Gare non sélectionnée'} + + + {heureArrivee || '--:--'} + +
+
+ + {/* Badges métadonnées */} +
+ {serviceAnnuelNom && ( + + 📅 {serviceAnnuelNom} + + )} + {ligne && ( + + 🛤️ {lignes.find(l => String(l.id) === String(ligne))?.nom || 'Ligne'} + + )} +
+
+ + {/* 3. NAVIGATION ONGLETS WCS SNCF */} +
+
+ {TABS.map(tab => { + const isActive = activeTab === tab.key; + return ( + + ); + })} +
+ + {/* Intégration du custom element wcs-tabs */} + + {TABS.map(tab => ( + + ))} + +
+ + {/* 4. CONTENU DE LA MODALE */} +
+ {formError && ( + + Veuillez corriger les erreurs + {formError} + + )} + + {/* ══ ONGLET 1: GÉNÉRAL ══ */} + {activeTab === 'general' && ( +
+ {/* Carte Identification */} + + +
+ + Identification du Train & Référentiel +
+ +
+ + Numéro de train * + setNumeroTrain(e.detail?.value ?? e.target?.value ?? '')} + onInput={(e) => setNumeroTrain(e.target.value ?? '')} + /> + Identifiant unique du sillon + + + + Type de train + { + const val = e.detail?.value ?? e.target?.value ?? ''; + handleTypeTrainChange(val); + }} + > + — Aucun type spécifié — + {typeTrainId && !typesTrainsList.some(t => String(t.id) === String(typeTrainId)) && ( + + {typeTrainNom || `Type #${typeTrainId}`} + + )} + {typesTrainsList.map(t => ( + + {t.nom} + + ))} + + +
+ +
+ + Ligne ferroviaire + { + const val = e.detail?.value ?? e.target?.value ?? ''; + setLigne(val); + }} + > + — Aucune ligne — + {lignes.map(l => ( + + {l.nom}{l.code ? ` (${l.code})` : ''} + + ))} + + + + + Service Annuel (SA) + { + const val = e.detail?.value ?? e.target?.value ?? ''; + const sel = servicesAnnuels.find(s => String(s.id) === String(val)); + setServiceAnnuelId(val ? String(val) : ''); + setServiceAnnuelNom(sel ? sel.nom : ''); + }} + > + — Aucun Service Annuel — + {servicesAnnuels.map(s => ( + + {s.nom} ({formatDate(s.date_debut)} → {formatDate(s.date_fin)}) + + ))} + + +
+
+
+ + {/* Carte Trajet Principal */} + + +
+ + Trajet Principal & Horaires Extrémités +
+ + {/* Gare & Heure de Départ */} +
+ + Gare de départ * + { + const val = e.detail?.value ?? e.target?.value ?? ''; + handleGareDepartChange(val); + }} + > + — Choisir une gare de départ — + {gareDepartId && !sortedGares.some(g => String(g.id) === String(gareDepartId)) && ( + + {gareDepartNom || `Gare #${gareDepartId}`} + + )} + {sortedGares.map(g => ( + + {g.nom} + + ))} + + Saisissez le nom pour filtrer automatiquement les gares + + + + Heure de départ * + setHeureDepart(e.detail?.value ?? e.target?.value ?? '')} + onWcsChange={(e) => setHeureDepart(e.detail?.value ?? e.target?.value ?? '')} + onInput={(e) => setHeureDepart(e.target.value ?? '')} + /> + +
+ + {/* Gare & Heure d'Arrivée */} +
+ + Gare d'arrivée * + { + const val = e.detail?.value ?? e.target?.value ?? ''; + handleGareArriveeChange(val); + }} + > + — Choisir une gare d'arrivée — + {gareArriveeId && !sortedGares.some(g => String(g.id) === String(gareArriveeId)) && ( + + {gareArriveeNom || `Gare #${gareArriveeId}`} + + )} + {sortedGares.map(g => ( + + {g.nom} + + ))} + + Saisissez le nom pour filtrer automatiquement les gares + + + + Heure d'arrivée * + setHeureArrivee(e.detail?.value ?? e.target?.value ?? '')} + onWcsChange={(e) => setHeureArrivee(e.detail?.value ?? e.target?.value ?? '')} + onInput={(e) => setHeureArrivee(e.target.value ?? '')} + /> + +
+
+
+
+ )} + + {/* ══ ONGLET 2: GARES DESSERVIES ══ */} + {activeTab === 'gares' && ( +
+
+
+

+ Arrêts intermédiaires desservis +

+

+ Indiquez les gares intermédiaires dans l'ordre chronologique de circulation. +

+
+ + + Ajouter un arrêt + +
+ + {garesDesservies.length === 0 ? ( +
+ +
+ Trajet direct sans arrêt intermédiaire +
+
+ Le train circule directement de {gareDepartNom || 'sa gare de départ'} à {gareArriveeNom || 'sa gare d\'arrivée'}. +
+ + + Ajouter le premier arrêt + +
+ ) : ( +
+ {garesDesservies.map((g, idx) => ( + + +
+ {/* Réordonnancement */} +
+ moveGareUp(idx)} + disabled={idx === 0} + title="Monter" + > + + + moveGareDown(idx)} + disabled={idx === garesDesservies.length - 1} + title="Descendre" + > + + +
+ + {/* Badge ordre */} + + Arrêt #{idx + 1} + + + {/* Champs WCS */} +
+ + Gare intermédiaire + { + const val = e.detail?.value ?? e.target?.value ?? ''; + handleGareDesservieSelectChange(idx, val); + }} + > + — Choisir une gare — + {g.gare_id && !sortedGares.some(gx => String(gx.id) === String(g.gare_id)) && ( + + {g.gare_nom || `Gare #${g.gare_id}`} + + )} + {sortedGares.map(gx => ( + + {gx.nom} + + ))} + + + + + Arrivée quai + updateGareDesservieField(idx, 'heure_arrivee', e.detail?.value ?? e.target?.value ?? '')} + onWcsChange={(e) => updateGareDesservieField(idx, 'heure_arrivee', e.detail?.value ?? e.target?.value ?? '')} + onInput={(e) => updateGareDesservieField(idx, 'heure_arrivee', e.target.value ?? '')} + /> + + + + Départ quai + updateGareDesservieField(idx, 'heure_depart', e.detail?.value ?? e.target?.value ?? '')} + onWcsChange={(e) => updateGareDesservieField(idx, 'heure_depart', e.detail?.value ?? e.target?.value ?? '')} + onInput={(e) => updateGareDesservieField(idx, 'heure_depart', e.target.value ?? '')} + /> + +
+ + {/* Bouton Supprimer */} + removeGareDesservie(idx)} + title="Supprimer cet arrêt" + > + + +
+
+
+ ))} +
+ )} +
+ )} + + {/* ══ ONGLET 3: CALENDRIER ══ */} + {activeTab === 'jours' && ( +
+
+

+ Dates et calendrier de circulation +

+

+ Activez ou désactivez les jours précis où ce train circule durant le Service Annuel. +

+
+
+ String(s.id) === String(serviceAnnuelId))} + selectedDays={selectedDatesSet} + onChange={handleCalendarChange} + /> +
+
+ )} + + {/* ══ ONGLET 4: MATÉRIEL ROULANT ══ */} + {activeTab === 'materiel' && ( +
+ {/* Voie de composition */} + + +
+
+ + Composition du train + {compositionTrain.length > 0 && ( + + {compositionTrain.length} motrice(s) / voiture(s) + + )} +
+ {compositionTrain.length > 0 && ( + setCompositionTrain([])} + > + + Vider la composition + + )} +
+ + {compositionTrain.length === 0 ? ( +
+ +
+ Aucun matériel roulant affecté +
+
+ Cliquez sur un matériel disponible ci-dessous pour composer la rame. +
+
+ ) : ( +
+
+
+ {compositionTrain.map((item, idx) => ( +
{ e.dataTransfer.setData('text/plain', String(idx)); e.dataTransfer.effectAllowed = 'move'; }} + onDragOver={e => { e.preventDefault(); setDragOverIndex(idx); }} + onDragLeave={() => setDragOverIndex(null)} + onDrop={e => { + e.preventDefault(); + const from = parseInt(e.dataTransfer.getData('text/plain'), 10); + if (from === idx) { setDragOverIndex(null); return; } + setCompositionTrain(prev => { + const copy = [...prev]; + const [moved] = copy.splice(from, 1); + copy.splice(idx, 0, moved); + return copy.map((it, i) => ({ ...it, position: i + 1 })); + }); + setDragOverIndex(null); + }} + onDragEnd={() => setDragOverIndex(null)} + className={`horaire-compo-wagon ${dragOverIndex === idx ? 'is-drag-over' : ''}`} + > +
+ {item.materiel_image_url ? ( + {item.materiel_nom + ) : ( + + )} +
+ + Voiture #{idx + 1} + + + {item.materiel_nom || item.materiel_nom_technique} + + { + e.stopPropagation(); + setCompositionTrain(p => p.filter((_, i) => i !== idx).map((it, i) => ({ ...it, position: i + 1 }))); + }} + title="Retirer" + > + + +
+ ))} +
+
+ )} + + + + {/* Matériels disponibles */} + + +
+ + Catalogue des matériels roulants disponibles +
+ + {materiels.length === 0 ? ( +
+ Aucun matériel enregistré dans la base de données. +
+ ) : ( +
+ {materiels.map(m => { + const inCompo = compositionTrain.filter(c => c.materiel_id === m.id).length; + const occupied = (() => { + if (!heureDepart || !heureArrivee) return false; + return horaires.some(h => { + if (!h?.materiel_roulant_id || String(h.materiel_roulant_id) !== String(m.id)) return false; + if (editingItem && String(h.id) === String(editingItem.id)) return false; + const hD = h.heure_depart || ''; const hA = h.heure_arrivee || ''; + return hD && hA && heureDepart < hA && heureArrivee > hD; + }); + })(); + + return ( +
0 ? 'is-in-compo' : ''}`} + onClick={() => { + if (occupied) return; + setCompositionTrain(p => [...p, { + id: `comp-${Date.now()}-${Math.random()}`, + materiel_id: m.id, + materiel_nom: m.nom || '', + materiel_nom_technique: m.nom_technique || '', + materiel_image_url: m.image_url || null, + position: p.length + 1 + }]); + setMaterielRoulantId(m.id); + }} + > +
+ {m.image_url ? ( + {m.nom} + ) : ( + + )} +
+ + {m.nom} + + {m.nom_technique && ( + + {m.nom_technique} + + )} +
+ {occupied ? ( + Occupé + ) : inCompo > 0 ? ( + {inCompo} dans la rame + ) : ( + + Ajouter + )} +
+
+ ); + })} +
+ )} +
+
+
+ )} + + {/* ══ ONGLET 5: SUBSTITUTION ══ */} + {activeTab === 'substitution' && ( + + +
+ + Remplacement & Substitution Routière +
+ +
+ + setEstSubstitution(!!(e.detail?.checked ?? e.target?.checked))} + > + Ce sillon est une substitution par autocar (travaux, coupure de voie, etc.) + + +
+ + {estSubstitution && ( +
+ + Motif de la substitution + setMotifSubstitution(e.detail?.value ?? e.target?.value ?? '')} + onInput={(e) => setMotifSubstitution(e.target.value ?? '')} + /> + Indiquez la raison du remplacement routier pour l'information voyageurs + + +
+ Suggestions rapides : + {['Travaux voie', 'Coupure caténaire', 'Remplacement matériel', 'Intempéries'].map(sug => ( + setMotifSubstitution(sug)} + > + {sug} + + ))} +
+
+ )} +
+
+ )} +
+ + {/* 5. FOOTER AVEC BOUTONS WCS */} +
+
+ {TABS.map(t => ( +
+ +
+ + Annuler + + + {saving ? ( + <> + + Enregistrement... + + ) : ( + + {isDuplicating + ? 'Enregistrer la copie' + : editingItem + ? 'Mettre à jour le sillon' + : 'Créer le sillon' + } + + )} + +
+
+ +
+
+ ); } diff --git a/src/components/HoraireModal/HoraireModal.test.js b/src/components/HoraireModal/HoraireModal.test.js new file mode 100644 index 0000000..83e2d7a --- /dev/null +++ b/src/components/HoraireModal/HoraireModal.test.js @@ -0,0 +1,185 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import HoraireModal from './HoraireModal'; + +// Mock dependencies +jest.mock('../../api/horaires', () => ({ + createHoraire: jest.fn(), + updateHoraire: jest.fn() +})); + +describe('HoraireModal (WCS SNCF Design System)', () => { + const mockGares = [ + { id: 1, nom: 'Paris Gare de Lyon' }, + { id: 2, nom: 'Lyon Part-Dieu' }, + { id: 3, nom: 'Marseille Saint-Charles' } + ]; + + const mockTypesTrains = [ + { id: 1, nom: 'TGV INOUI' }, + { id: 2, nom: 'TER' } + ]; + + const mockLignes = [ + { id: 1, nom: 'Paris - Lyon - Marseille', code: 'PLM' } + ]; + + const mockServicesAnnuels = [ + { id: 1, nom: 'SA 2026', date_debut: '2025-12-14', date_fin: '2026-12-12' } + ]; + + it('devrait rendre la modale avec les composants WCS de navigation et les selects autocomplete', () => { + const { container } = render( + + ); + + // Vérifier la présence des onglets WCS SNCF affichés + const tabHeaders = container.querySelectorAll('.wcs-tab-header'); + expect(tabHeaders.length).toBe(5); + expect(container.querySelector('.wcs-tabs-headers')).toBeInTheDocument(); + expect(container.querySelector('wcs-tabs')).toBeInTheDocument(); + expect(container.querySelectorAll('wcs-tab').length).toBe(5); + + // Vérifier les champs de recherche autocomplete + const autocompleteSelects = container.querySelectorAll('wcs-select#select-autocomplete'); + expect(autocompleteSelects.length).toBeGreaterThanOrEqual(3); + + // Vérifier spécifiquement type de train, gare départ et gare arrivée + expect(container.querySelector('wcs-select[data-field="typeTrainId"]#select-autocomplete')).toBeInTheDocument(); + expect(container.querySelector('wcs-select[data-field="gareDepartId"]#select-autocomplete')).toBeInTheDocument(); + expect(container.querySelector('wcs-select[data-field="gareArriveeId"]#select-autocomplete')).toBeInTheDocument(); + + // Vérifier numéro de train et heures + expect(container.querySelector('wcs-input[data-field="numeroTrain"]')).toBeInTheDocument(); + expect(container.querySelector('wcs-input[data-field="heureDepart"]')).toBeInTheDocument(); + expect(container.querySelector('wcs-input[data-field="heureArrivee"]')).toBeInTheDocument(); + }); + + it('devrait permettre de naviguer entre les onglets du formulaire', () => { + const { container } = render( + + ); + + // Au départ, l'onglet Général est actif + const tabs = container.querySelectorAll('.wcs-tab-header'); + expect(tabs[0]).toHaveClass('active'); + expect(screen.getByText('Identification du Train & Référentiel')).toBeInTheDocument(); + + // Cliquer sur le 2ème onglet (Gares desservies) + fireEvent.click(tabs[1]); + expect(tabs[1]).toHaveClass('active'); + expect(screen.getByText('Arrêts intermédiaires desservis')).toBeInTheDocument(); + + // Cliquer sur le 5ème onglet (Substitution) + fireEvent.click(tabs[4]); + expect(tabs[4]).toHaveClass('active'); + expect(screen.getByText('Remplacement & Substitution Routière')).toBeInTheDocument(); + }); + + it('devrait initialiser les champs et les selects autocomplete à partir des données de départ', () => { + const initialData = { + id: 42, + numero_train: 'TGV 6601', + gare_depart_id: '1', + gare_depart_nom: 'Paris Gare de Lyon', + heure_depart: '08:00', + gare_arrivee_id: '2', + gare_arrivee_nom: 'Lyon Part-Dieu', + heure_arrivee: '10:00', + type_train_id: '1', + type_train: 'TGV INOUI', + ligne_id: '1', + service_annuel_id: '1' + }; + + const { container } = render( + + ); + + const numInput = container.querySelector('wcs-input[data-field="numeroTrain"]'); + expect(numInput).toHaveAttribute('value', 'TGV 6601'); + + const gareDepSelect = container.querySelector('wcs-select[data-field="gareDepartId"]'); + expect(gareDepSelect).toHaveAttribute('value', '1'); + + const gareArrSelect = container.querySelector('wcs-select[data-field="gareArriveeId"]'); + expect(gareArrSelect).toHaveAttribute('value', '2'); + + const typeSelect = container.querySelector('wcs-select[data-field="typeTrainId"]'); + expect(typeSelect).toHaveAttribute('value', '1'); + + const hDepInput = container.querySelector('wcs-input[data-field="heureDepart"]'); + expect(hDepInput).toHaveAttribute('value', '08:00'); + + const hArrInput = container.querySelector('wcs-input[data-field="heureArrivee"]'); + expect(hArrInput).toHaveAttribute('value', '10:00'); + }); + + it('devrait déclencher onSaveOverride lors de la soumission si fourni', async () => { + const handleSaveOverride = jest.fn(); + const initialData = { + id: 99, + numero_train: 'TER 8801', + gare_depart_id: '1', + gare_depart_nom: 'Paris Gare de Lyon', + heure_depart: '09:00', + gare_arrivee_id: '2', + gare_arrivee_nom: 'Lyon Part-Dieu', + heure_arrivee: '11:00' + }; + + const { container } = render( + + ); + + const submitBtn = container.querySelector('wcs-button.wcs-primary'); + fireEvent.click(submitBtn); + + expect(handleSaveOverride).toHaveBeenCalledTimes(1); + expect(handleSaveOverride).toHaveBeenCalledWith( + expect.objectContaining({ + numero_train: 'TER 8801', + gare_depart_id: '1', + gare_arrivee_id: '2', + heure_depart: '09:00', + heure_arrivee: '11:00' + }), + expect.any(Array) + ); + }); +}); diff --git a/src/pages/horaires/ImportsPage.css b/src/pages/horaires/ImportsPage.css index 6dcd87c..74ee516 100644 --- a/src/pages/horaires/ImportsPage.css +++ b/src/pages/horaires/ImportsPage.css @@ -2012,5 +2012,313 @@ body.dark-mode .gtfs-accordion-trip-row.selected, background: #1e3a8a; } +/* ============================================================================ + SYSTÈME DE RÉSOLUTION DES DONNÉES MANQUANTES AVANT IMPORT (PRÉ-ENREGISTREMENT) + ============================================================================ */ + +.missing-entities-hero { + background: linear-gradient(135deg, #1e3a8a, #3b82f6); + border-radius: 14px; + padding: 22px 26px; + color: #ffffff; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 18px; + margin-bottom: 24px; + box-shadow: 0 8px 24px rgba(37, 99, 235, 0.25); +} + +.missing-entities-hero.all-resolved { + background: linear-gradient(135deg, #15803d, #22c55e); + box-shadow: 0 8px 24px rgba(34, 197, 94, 0.25); +} + +.missing-entities-hero-content h4 { + margin: 0 0 6px 0; + font-size: 1.25rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 10px; +} + +.missing-entities-hero-content p { + margin: 0; + font-size: 0.95rem; + opacity: 0.92; + line-height: 1.4; +} + +.missing-counts-pill-group { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +.missing-count-pill { + background: rgba(255, 255, 255, 0.18); + backdrop-filter: blur(8px); + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid rgba(255, 255, 255, 0.25); +} + +.missing-count-pill.resolved { + background: rgba(34, 197, 94, 0.25); + border-color: rgba(34, 197, 94, 0.4); +} + +.btn-magic-resolve { + background: #ffffff; + color: #1e3a8a; + border: none; + border-radius: 10px; + padding: 12px 22px; + font-size: 0.95rem; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 10px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15); + transition: all 0.2s ease-in-out; +} + +.btn-magic-resolve:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); + background: #f8fafc; +} + +.btn-magic-resolve:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +/* Onglets de résolution */ +.missing-tabs-nav { + display: flex; + gap: 8px; + border-bottom: 2px solid #e2e8f0; + margin-bottom: 20px; + overflow-x: auto; + padding-bottom: 2px; +} + +.missing-tab-btn { + background: transparent; + border: none; + padding: 10px 18px; + font-size: 0.92rem; + font-weight: 600; + color: #64748b; + cursor: pointer; + border-bottom: 3px solid transparent; + margin-bottom: -4px; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + white-space: nowrap; +} + +.missing-tab-btn:hover { + color: #1e293b; +} + +.missing-tab-btn.active { + color: #2563eb; + border-bottom-color: #2563eb; +} + +.missing-tab-badge { + background: #fee2e2; + color: #b91c1c; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 700; +} + +.missing-tab-badge.zero { + background: #dcfce7; + color: #15803d; +} + +.missing-action-bar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; + padding: 12px 16px; + background: #f8fafc; + border-radius: 10px; + border: 1px solid #e2e8f0; +} + +.btn-quick-create { + background: #2563eb; + color: #ffffff; + border: none; + border-radius: 6px; + padding: 6px 14px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn-quick-create:hover:not(:disabled) { + background: #1d4ed8; +} + +.btn-batch-create { + background: #0ea5e9; + color: #ffffff; + border: none; + border-radius: 8px; + padding: 8px 16px; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + transition: background 0.15s; +} + +.btn-batch-create:hover:not(:disabled) { + background: #0284c7; +} + +.mapping-select { + padding: 6px 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 0.85rem; + background-color: #ffffff; + color: #1e293b; + max-width: 260px; + cursor: pointer; +} + +.mapping-select:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); +} + +.status-badge-created { + display: inline-flex; + align-items: center; + gap: 5px; + color: #15803d; + background: #dcfce7; + padding: 3px 10px; + border-radius: 12px; + font-size: 0.82rem; + font-weight: 600; +} + +.status-badge-missing { + display: inline-flex; + align-items: center; + gap: 5px; + color: #b91c1c; + background: #fee2e2; + padding: 3px 10px; + border-radius: 12px; + font-size: 0.82rem; + font-weight: 600; +} + +.status-badge-mapped { + display: inline-flex; + align-items: center; + gap: 5px; + color: #0369a1; + background: #e0f2fe; + padding: 3px 10px; + border-radius: 12px; + font-size: 0.82rem; + font-weight: 600; +} + +/* Barre de progression d'import en direct */ +.import-live-progress { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 24px; + margin: 20px 0; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); + text-align: center; +} + +.import-live-progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + font-weight: 600; + color: #334155; +} + +.import-progress-bar-bg { + width: 100%; + height: 12px; + background: #e2e8f0; + border-radius: 6px; + overflow: hidden; + position: relative; +} + +.import-progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, #2563eb, #38bdf8); + border-radius: 6px; + transition: width 0.3s ease; +} + +.import-live-train-info { + margin-top: 10px; + font-size: 0.9rem; + color: #64748b; +} + +/* Dark mode adjustments */ +body.dark-mode .missing-action-bar, +[data-theme="dark"] .missing-action-bar { + background: #1e293b; + border-color: #334155; +} + +body.dark-mode .mapping-select, +[data-theme="dark"] .mapping-select { + background: #0f172a; + color: #f8fafc; + border-color: #334155; +} + +body.dark-mode .import-live-progress, +[data-theme="dark"] .import-live-progress { + background: #1e293b; + border-color: #334155; +} + + diff --git a/src/pages/horaires/ImportsPage.jsx b/src/pages/horaires/ImportsPage.jsx index f2b0bd0..9962ec9 100644 --- a/src/pages/horaires/ImportsPage.jsx +++ b/src/pages/horaires/ImportsPage.jsx @@ -3,9 +3,9 @@ import NavPanel from '../../components/nav-panel/NavPanel'; import * as XLSX from 'xlsx'; import { createHoraire, getAllHoraires } from '../../api/horaires'; import { getAllGares, createGare } from '../../api/gares'; -import { getAllLignes } from '../../api/lignes'; +import { getAllLignes, createLigne } from '../../api/lignes'; import { getAllMateriels } from '../../api/materielsRoulants'; -import { getAllTypesTrains } from '../../api/typesTrains'; +import { getAllTypesTrains, createTypeTrain } from '../../api/typesTrains'; import { createServiceAnnuel, getAllServicesAnnuels } from '../../api/servicesAnnuels'; import { parseGTFSZip, @@ -367,10 +367,12 @@ function applyServiceMatching(rows, services) { key: window.key, start: window.start, end: window.end, - rows: [] + rows: [], + rowIndices: [] }); } missingByWindow.get(window.key).rows.push(index); + missingByWindow.get(window.key).rowIndices.push(index); } }); @@ -1084,11 +1086,19 @@ function buildHorairePayload(row, allGares, lignes, materiels, typesTrains, serv }); } + const resolvedTypeId = validIdOrNull(row.type_train_id, typesTrains || []) || ( + (typesTrains || []).find(t => String(t.nom || '').trim().toLowerCase() === String(row.type_train || '').trim().toLowerCase())?.id || null + ); + + const resolvedLigneId = validIdOrNull(row.ligne_id, lignes || []) || ( + (lignes || []).find(l => String(l.nom || '').trim().toLowerCase() === String(row.ligne_nom || '').trim().toLowerCase())?.id || null + ); + const payload = { numero_train: String(row.numero_train || '').trim(), type_train: String(row.type_train || '').trim(), - type_train_id: validIdOrNull(row.type_train_id, typesTrains || []), - ligne_id: validIdOrNull(row.ligne_id, lignes || []), + type_train_id: resolvedTypeId, + ligne_id: resolvedLigneId, ligne_nom: String(row.ligne_nom || '').trim() || null, gare_depart_id: validateGareId(row.gare_depart_id, gareDepartNom), gare_depart_nom: gareDepartNom, @@ -1107,6 +1117,11 @@ function buildHorairePayload(row, allGares, lignes, materiels, typesTrains, serv circule_dimanches_feries: parseBoolean(row.circule_dimanches_feries, false), jours_personnalises: joursPerso, jours_non_circulation: parseJsonArray(row.jours_non_circulation), + calendrier_circulation: parseJsonArray(row.calendrier_circulation), + date_debut: row.date_debut || null, + date_fin: row.date_fin || null, + transporteur: row.transporteur || 'SNCF Voyageurs', + statut: row.statut || 'a_l_heure', materiel_roulant_id: validIdOrNull(row.materiel_roulant_id, materiels || []), composition_train: compositionTrain, service_annuel_id: resolveServiceAnnuelId(), @@ -1212,6 +1227,12 @@ export default function ImportsPage() { const [existingHoraires, setExistingHoraires] = useState([]); const [pendingNotification, setPendingNotification] = useState(null); const [garesManquantes, setGaresManquantes] = useState([]); + const [typesManquants, setTypesManquants] = useState([]); + const [lignesManquantes, setLignesManquantes] = useState([]); + const [missingEntitiesTab, setMissingEntitiesTab] = useState('gares'); + const [isResolvingAll, setIsResolvingAll] = useState(false); + const [resolveProgressMessage, setResolveProgressMessage] = useState(''); + const [importProgress, setImportProgress] = useState(null); const [creatingGarePour, setCreatingGarePour] = useState(null); const [gareFormData, setGareFormData] = useState(INITIAL_GARE_FORM); const [gareFormError, setGareFormError] = useState(''); @@ -2083,15 +2104,339 @@ export default function ImportsPage() { return Array.from(selectedRows).sort((a, b) => a - b).map(i => ({ index: i, row: parsedRows[i] })); }, [selectedRows, parsedRows]); - const computeGaresManquantes = (rows, garesDB) => { + const analyzeAllMissingEntities = (rows, garesDB, typesDB, lignesDB, servicesDB) => { + // 1. Gares const allNames = extractAllGareNames(rows); - const missing = allNames.filter(nom => { - const lower = nom.toLowerCase(); - return !garesDB.some(g => (g.nom || '').toLowerCase() === lower); + const garesMap = new Map(); + (garesDB || []).forEach(g => { + if (g?.nom) garesMap.set(g.nom.trim().toLowerCase(), g); }); - const list = missing.map(nom => ({ nom, created: false, gareId: null })); - setGaresManquantes(list); - return missing; + + const missingGaresList = []; + allNames.forEach(nom => { + const cleanNom = String(nom || '').trim(); + if (cleanNom && !garesMap.has(cleanNom.toLowerCase())) { + let count = 0; + (rows || []).forEach(r => { + if (String(r.gare_depart_nom || '').trim().toLowerCase() === cleanNom.toLowerCase()) count++; + if (String(r.gare_arrivee_nom || '').trim().toLowerCase() === cleanNom.toLowerCase()) count++; + const stops = parseJsonArray(r.gares_desservies); + stops.forEach(s => { + if (String(s.gare_nom || '').trim().toLowerCase() === cleanNom.toLowerCase()) count++; + }); + }); + missingGaresList.push({ nom: cleanNom, count, created: false, gareId: null, mappedGareId: null }); + } + }); + setGaresManquantes(missingGaresList); + + // 2. Types de Train + const typesMap = new Map(); + (typesDB || []).forEach(t => { + if (t?.nom) typesMap.set(t.nom.trim().toLowerCase(), t); + }); + const missingTypesList = []; + const detectedTypesSet = new Set(); + (rows || []).forEach(r => { + const tNom = String(r.type_train || '').trim(); + if (tNom && !typesMap.has(tNom.toLowerCase()) && !detectedTypesSet.has(tNom.toLowerCase())) { + detectedTypesSet.add(tNom.toLowerCase()); + let count = 0; + rows.forEach(rx => { + if (String(rx.type_train || '').trim().toLowerCase() === tNom.toLowerCase()) count++; + }); + missingTypesList.push({ nom: tNom, count, created: false, typeId: null, mappedTypeId: null }); + } + }); + setTypesManquants(missingTypesList); + + // 3. Lignes + const lignesMap = new Map(); + (lignesDB || []).forEach(l => { + if (l?.nom) lignesMap.set(l.nom.trim().toLowerCase(), l); + }); + const missingLignesList = []; + const detectedLignesSet = new Set(); + (rows || []).forEach(r => { + const lNom = String(r.ligne_nom || '').trim(); + if (lNom && !lignesMap.has(lNom.toLowerCase()) && !detectedLignesSet.has(lNom.toLowerCase())) { + detectedLignesSet.add(lNom.toLowerCase()); + let count = 0; + rows.forEach(rx => { + if (String(rx.ligne_nom || '').trim().toLowerCase() === lNom.toLowerCase()) count++; + }); + missingLignesList.push({ nom: lNom, count, created: false, ligneId: null, mappedLigneId: null }); + } + }); + setLignesManquantes(missingLignesList); + + return { + hasMissing: missingGaresList.length > 0 || missingTypesList.length > 0 || missingLignesList.length > 0, + missingGares: missingGaresList, + missingTypes: missingTypesList, + missingLignes: missingLignesList + }; + }; + + const handleAutoCreateGare = async (nom) => { + try { + const res = await createGare({ + nom: nom.trim(), + type: 'ville', + quais: [{ nom: '1', distance_metres: 0 }], + informations: {}, + services: [] + }); + if (res.error) { + alert(`Erreur création gare "${nom}": ${res.error.message || 'Erreur inconnue'}`); + return null; + } + const newGare = res.data; + setAllGares(prev => [...prev, newGare]); + setGaresManquantes(prev => prev.map(g => g.nom.toLowerCase() === nom.toLowerCase() ? { ...g, created: true, gareId: newGare.id } : g)); + + // Synchroniser immédiatement les identifiants dans parsedRows + setParsedRows(prevRows => prevRows.map(row => { + const isDep = String(row.gare_depart_nom || '').trim().toLowerCase() === nom.toLowerCase(); + const isArr = String(row.gare_arrivee_nom || '').trim().toLowerCase() === nom.toLowerCase(); + let stops = parseJsonArray(row.gares_desservies); + let stopsChanged = false; + stops = stops.map(s => { + if (String(s.gare_nom || '').trim().toLowerCase() === nom.toLowerCase()) { + stopsChanged = true; + return { ...s, gare_id: newGare.id }; + } + return s; + }); + if (isDep || isArr || stopsChanged) { + return { + ...row, + ...(isDep ? { gare_depart_id: newGare.id } : {}), + ...(isArr ? { gare_arrivee_id: newGare.id } : {}), + ...(stopsChanged ? { gares_desservies: stops } : {}) + }; + } + return row; + })); + return newGare; + } catch (e) { + console.error('Erreur handleAutoCreateGare', e); + return null; + } + }; + + const handleMapGare = (nom, targetGareId) => { + if (!targetGareId) return; + const targetGare = allGares.find(g => String(g.id) === String(targetGareId)); + if (!targetGare) return; + + setGaresManquantes(prev => prev.map(g => g.nom.toLowerCase() === nom.toLowerCase() ? { ...g, mappedGareId: targetGare.id, mappedGareNom: targetGare.nom } : g)); + + setParsedRows(prevRows => prevRows.map(row => { + const isDep = String(row.gare_depart_nom || '').trim().toLowerCase() === nom.toLowerCase(); + const isArr = String(row.gare_arrivee_nom || '').trim().toLowerCase() === nom.toLowerCase(); + let stops = parseJsonArray(row.gares_desservies); + let stopsChanged = false; + stops = stops.map(s => { + if (String(s.gare_nom || '').trim().toLowerCase() === nom.toLowerCase()) { + stopsChanged = true; + return { ...s, gare_id: targetGare.id }; + } + return s; + }); + if (isDep || isArr || stopsChanged) { + return { + ...row, + ...(isDep ? { gare_depart_id: targetGare.id } : {}), + ...(isArr ? { gare_arrivee_id: targetGare.id } : {}), + ...(stopsChanged ? { gares_desservies: stops } : {}) + }; + } + return row; + })); + }; + + const handleBatchCreateGares = async () => { + const toCreate = garesManquantes.filter(g => !g.created && !g.mappedGareId); + if (toCreate.length === 0) return; + setResolveProgressMessage(`Création de ${toCreate.length} gare(s)...`); + for (const item of toCreate) { + await handleAutoCreateGare(item.nom); + } + setResolveProgressMessage(''); + }; + + const handleAutoCreateType = async (nom) => { + try { + const code = nom.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'TRAIN'; + const res = await createTypeTrain({ + nom: nom.trim(), + code, + couleur: '#0074d9', + description: 'Créé automatiquement lors de l’importation des horaires' + }); + if (res.error) { + alert(`Erreur création type "${nom}": ${res.error.message || 'Erreur'}`); + return null; + } + const newType = res.data; + setTypesTrainsList(prev => [...prev, newType]); + setTypesManquants(prev => prev.map(t => t.nom.toLowerCase() === nom.toLowerCase() ? { ...t, created: true, typeId: newType.id } : t)); + + setParsedRows(prevRows => prevRows.map(row => { + if (String(row.type_train || '').trim().toLowerCase() === nom.toLowerCase()) { + return { ...row, type_train_id: newType.id, type_train: newType.nom }; + } + return row; + })); + return newType; + } catch (e) { + console.error('Erreur handleAutoCreateType', e); + return null; + } + }; + + const handleMapType = (nom, targetTypeId) => { + if (!targetTypeId) return; + const targetType = typesTrainsList.find(t => String(t.id) === String(targetTypeId)); + if (!targetType) return; + setTypesManquants(prev => prev.map(t => t.nom.toLowerCase() === nom.toLowerCase() ? { ...t, mappedTypeId: targetType.id, mappedTypeNom: targetType.nom } : t)); + setParsedRows(prevRows => prevRows.map(row => { + if (String(row.type_train || '').trim().toLowerCase() === nom.toLowerCase()) { + return { ...row, type_train_id: targetType.id, type_train: targetType.nom }; + } + return row; + })); + }; + + const handleBatchCreateTypes = async () => { + const toCreate = typesManquants.filter(t => !t.created && !t.mappedTypeId); + if (toCreate.length === 0) return; + for (const item of toCreate) { + await handleAutoCreateType(item.nom); + } + }; + + const handleAutoCreateLigne = async (nom) => { + try { + const sampleRow = parsedRows.find(r => String(r.ligne_nom || '').trim().toLowerCase() === nom.toLowerCase()); + const code = nom.replace(/[^A-Z0-9]/gi, '').slice(0, 8).toUpperCase() || 'LIGNE'; + const res = await createLigne({ + nom: nom.trim(), + code, + gare_depart_id: sampleRow?.gare_depart_id || null, + gare_arrivee_id: sampleRow?.gare_arrivee_id || null, + actif: true + }, []); + if (res.error) { + alert(`Erreur création ligne "${nom}": ${res.error.message || 'Erreur'}`); + return null; + } + const newLigne = res.data; + setLignes(prev => [...prev, newLigne]); + setLignesManquantes(prev => prev.map(l => l.nom.toLowerCase() === nom.toLowerCase() ? { ...l, created: true, ligneId: newLigne.id } : l)); + + setParsedRows(prevRows => prevRows.map(row => { + if (String(row.ligne_nom || '').trim().toLowerCase() === nom.toLowerCase()) { + return { ...row, ligne_id: newLigne.id, ligne_nom: newLigne.nom }; + } + return row; + })); + return newLigne; + } catch (e) { + console.error('Erreur handleAutoCreateLigne', e); + return null; + } + }; + + const handleMapLigne = (nom, targetLigneId) => { + if (!targetLigneId) return; + const targetLigne = lignes.find(l => String(l.id) === String(targetLigneId)); + if (!targetLigne) return; + setLignesManquantes(prev => prev.map(l => l.nom.toLowerCase() === nom.toLowerCase() ? { ...l, mappedLigneId: targetLigne.id, mappedLigneNom: targetLigne.nom } : l)); + setParsedRows(prevRows => prevRows.map(row => { + if (String(row.ligne_nom || '').trim().toLowerCase() === nom.toLowerCase()) { + return { ...row, ligne_id: targetLigne.id, ligne_nom: targetLigne.nom }; + } + return row; + })); + }; + + const handleBatchCreateLignes = async () => { + const toCreate = lignesManquantes.filter(l => !l.created && !l.mappedLigneId); + if (toCreate.length === 0) return; + for (const item of toCreate) { + await handleAutoCreateLigne(item.nom); + } + }; + + const handleResolveAllMissingEntities = async () => { + setIsResolvingAll(true); + try { + // 1. Gares manquantes + const garesToCreate = garesManquantes.filter(g => !g.created && !g.mappedGareId); + if (garesToCreate.length > 0) { + setResolveProgressMessage(`Création de ${garesToCreate.length} gare(s)...`); + for (const g of garesToCreate) { + await handleAutoCreateGare(g.nom); + } + } + + // 2. Types de train + const typesToCreate = typesManquants.filter(t => !t.created && !t.mappedTypeId); + if (typesToCreate.length > 0) { + setResolveProgressMessage(`Création de ${typesToCreate.length} type(s) de train...`); + for (const t of typesToCreate) { + await handleAutoCreateType(t.nom); + } + } + + // 3. Lignes + const lignesToCreate = lignesManquantes.filter(l => !l.created && !l.mappedLigneId); + if (lignesToCreate.length > 0) { + setResolveProgressMessage(`Création de ${lignesToCreate.length} ligne(s)...`); + for (const l of lignesToCreate) { + await handleAutoCreateLigne(l.nom); + } + } + + // 4. Services Annuels + if (missingServiceWindows.length > 0) { + setResolveProgressMessage(`Création du Service Annuel...`); + for (const w of missingServiceWindows) { + const start = w.start || new Date().toISOString().slice(0, 10); + const end = w.end || new Date().toISOString().slice(0, 10); + const resSA = await createServiceAnnuel({ + nom: `SA ${start.slice(0, 4)}`, + code: `SA${start.slice(0, 4)}`, + date_debut: start, + date_fin: end, + description: 'Créé automatiquement lors de l’importation', + actif: true + }); + if (resSA.data) { + const newSA = resSA.data; + setServicesAnnuels(prev => [...prev, newSA]); + const indices = w.rows || w.rowIndices || []; + setParsedRows(prevRows => prevRows.map(row => { + if (indices.includes(parsedRows.indexOf(row))) { + return { ...row, service_annuel_id: newSA.id, service_annuel_nom: newSA.nom }; + } + return row; + })); + } + } + setMissingServiceWindows([]); + } + + setResolveProgressMessage('✅ Toutes les données préalables sont enregistrées en base !'); + setTimeout(() => setResolveProgressMessage(''), 2500); + } catch (err) { + console.error('Erreur handleResolveAllMissingEntities', err); + alert(`Erreur lors de la résolution automatique : ${err?.message || err}`); + } finally { + setIsResolvingAll(false); + } }; const repriseImport = () => { @@ -2104,8 +2449,8 @@ export default function ImportsPage() { setFileName(fn || 'fichier import'); setDetectedHeaders(headers); setPendingNotification(null); - const missing = computeGaresManquantes(updatedRows, allGares); - setStep(getNextStepAfterImport(missing.length > 0, missingWindows.length > 0)); + const missingInfo = analyzeAllMissingEntities(updatedRows, allGares, typesTrainsList, lignes, servicesAnnuels); + setStep(missingInfo.hasMissing ? 2 : (missingWindows.length > 0 ? 3 : 5)); }; const dismissPending = () => { @@ -2132,8 +2477,8 @@ export default function ImportsPage() { updatedRows.map((r, i) => ({ r, i })).filter(({ r }) => !r._duplicate).map(({ i }) => i) )); setImportSummary(null); - const missing = computeGaresManquantes(updatedRows, allGares); - setStep(getNextStepAfterImport(missing.length > 0, missingWindows.length > 0)); + const missingInfo = analyzeAllMissingEntities(updatedRows, allGares, typesTrainsList, lignes, servicesAnnuels); + setStep(missingInfo.hasMissing ? 2 : (missingWindows.length > 0 ? 3 : (enableExtrapolation ? 4 : 5))); } catch (err) { if (err?.name === 'AbortError') return; console.error('Erreur lecture Excel', err); @@ -2216,6 +2561,31 @@ export default function ImportsPage() { ? { ...g, created: true, gareId: newGare.id } : g ) ); + + // Mettre à jour immédiatement parsedRows + setParsedRows(prevRows => prevRows.map(row => { + const isDep = String(row.gare_depart_nom || '').trim().toLowerCase() === nom.toLowerCase(); + const isArr = String(row.gare_arrivee_nom || '').trim().toLowerCase() === nom.toLowerCase(); + let stops = parseJsonArray(row.gares_desservies); + let stopsChanged = false; + stops = stops.map(s => { + if (String(s.gare_nom || '').trim().toLowerCase() === nom.toLowerCase()) { + stopsChanged = true; + return { ...s, gare_id: newGare.id }; + } + return s; + }); + if (isDep || isArr || stopsChanged) { + return { + ...row, + ...(isDep ? { gare_depart_id: newGare.id } : {}), + ...(isArr ? { gare_arrivee_id: newGare.id } : {}), + ...(stopsChanged ? { gares_desservies: stops } : {}) + }; + } + return row; + })); + closeGareModal(); } catch (err) { setGareFormError('Erreur inattendue'); @@ -2518,10 +2888,10 @@ export default function ImportsPage() { )); setImportSummary(null); - const missingGares = computeGaresManquantes(updatedRows, allGares); + const missingInfo = analyzeAllMissingEntities(updatedRows, allGares, typesTrainsList, lignes, servicesAnnuels); setImportSourceMode('excel'); // Transition into standard review/import pipeline - if (missingGares.length > 0) { + if (missingInfo.hasMissing) { setStep(2); } else if (missingWindows.length > 0) { setStep(3); @@ -2595,6 +2965,7 @@ export default function ImportsPage() { return; } setImporting(true); + setImportProgress({ current: 0, total: selectedIndexes.length, percent: 0, currentTrain: '' }); const results = []; let skippedCount = 0; @@ -2603,8 +2974,16 @@ export default function ImportsPage() { if (s?.id) servicesById[String(s.id)] = s; }); - for (const index of selectedIndexes) { + for (let i = 0; i < selectedIndexes.length; i++) { + const index = selectedIndexes[i]; const row = parsedRows[index] || {}; + const percent = Math.round(((i + 1) / selectedIndexes.length) * 100); + setImportProgress({ + current: i + 1, + total: selectedIndexes.length, + percent, + currentTrain: row.numero_train || `Ligne #${index + 1}` + }); // Strict duplicate check against database records (existingHoraires) const classification = classifyRowAgainstExisting(row, existingHoraires, servicesById); @@ -2636,6 +3015,7 @@ export default function ImportsPage() { failed, details: results.filter(r => !r.ok) }); + setImportProgress(null); setImporting(false); if (failed === 0) clearPendingFromStorage(); setStep(6); @@ -2643,7 +3023,7 @@ export default function ImportsPage() { const STEPS = [ { n: 1, label: 'Import' }, - { n: 2, label: 'Gares manquantes' }, + { n: 2, label: 'Données préalables' }, { n: 3, label: 'Service Annuel' }, { n: 4, label: 'Normalisation (Optionnelle)' }, { n: 5, label: 'Vérification & Sélection' }, @@ -3781,51 +4161,461 @@ export default function ImportsPage() {
)} - {step === 2 && ( -
-

Gares manquantes dans la base

-
- setStep(1)}>Retour - setStep(missingServiceWindows.length > 0 ? 3 : (enableExtrapolation ? 4 : 5))}> - Continuer vers {missingServiceWindows.length > 0 ? 'le Service Annuel' : (enableExtrapolation ? 'la Normalisation' : 'la Sélection')} - - {garesManquantes.some(g => !g.created) && ( - - ⚠️ {garesManquantes.filter(g => !g.created).length} gare(s) non encore créées - + {step === 2 && (() => { + const unresolvedGares = (garesManquantes || []).filter(g => !g?.created && !g?.mappedGareId).length; + const unresolvedTypes = (typesManquants || []).filter(t => !t?.created && !t?.mappedTypeId).length; + const unresolvedLignes = (lignesManquantes || []).filter(l => !l?.created && !l?.mappedLigneId).length; + const unresolvedServices = (missingServiceWindows || []).length; + const totalUnresolved = unresolvedGares + unresolvedTypes + unresolvedLignes + unresolvedServices; + + return ( +
+
+

+ Données préalables et référentiels +

+
+ setStep(1)}>Retour + setStep(missingServiceWindows.length > 0 ? 3 : (enableExtrapolation ? 4 : 5))}> + Continuer vers {missingServiceWindows.length > 0 ? 'le Service Annuel' : (enableExtrapolation ? 'la Normalisation' : 'la Sélection')} + + {totalUnresolved > 0 && ( + + ⚠️ {totalUnresolved} entité(s) non encore résolue(s) + + )} +
+
+ + {/* Hero Card for 1-Click Master Resolution */} +
+
+
+

⚡ Résolution intelligente des entités manquantes

+

+ Certaines gares, types de train, lignes ou services annuels mentionnés dans votre fichier n'existent pas encore dans Supabase. + Vous pouvez les créer automatiquement ou les associer à des enregistrements existants pour garantir l'intégrité de vos horaires. +

+
+ + {unresolvedGares === 0 ? '✅ Gares : OK' : `📍 ${unresolvedGares} gare(s) à créer`} + + + {unresolvedTypes === 0 ? '✅ Types : OK' : `🚆 ${unresolvedTypes} type(s) à créer`} + + + {unresolvedLignes === 0 ? '✅ Lignes : OK' : `🛤️ ${unresolvedLignes} ligne(s) à créer`} + + {unresolvedServices > 0 && ( + + 📅 {unresolvedServices} service(s) annuel(s) à créer + + )} +
+
+ +
+ + {resolveProgressMessage && ( +
+ + {resolveProgressMessage} +
+ )} +
+ + {/* Navigation Sub-Tabs */} +
+ + + + {missingServiceWindows.length > 0 && ( + + )} +
+ + {/* Onglet Gares */} + {missingEntitiesTab === 'gares' && ( +
+
+
+ {garesManquantes.length} gare(s) détectée(s) dans le fichier dont {unresolvedGares} non enregistrée(s). +
+ {unresolvedGares > 0 && ( + + )} +
+ +
+ + + + + + + + + + + + {garesManquantes.length === 0 ? ( + + + + ) : ( + garesManquantes.map((g) => { + const isCreated = g.created; + const isMapped = !!g.mappedGareId; + return ( + + + + + + + + ); + }) + )} + +
StatutNom de la gareFréquence fichierAssocier à une gare existanteActions
+ Toutes les gares du fichier existent déjà dans Supabase. +
+ {isCreated ? ( + ✅ Enregistrée + ) : isMapped ? ( + 🔗 Associée + ) : ( + ❌ Non trouvée + )} + {g.nom} + {g.count} fois + + + + {!isCreated && ( +
+ + +
+ )} +
+
+
+ )} + + {/* Onglet Types de Train */} + {missingEntitiesTab === 'types' && ( +
+
+
+ {typesManquants.length} type(s) de train détecté(s) dont {unresolvedTypes} non enregistré(s). +
+ {unresolvedTypes > 0 && ( + + )} +
+ +
+ + + + + + + + + + + + {typesManquants.length === 0 ? ( + + + + ) : ( + typesManquants.map((t) => { + const isCreated = t.created; + const isMapped = !!t.mappedTypeId; + return ( + + + + + + + + ); + }) + )} + +
StatutNom du typeFréquence fichierAssocier à un type existantAction
+ Tous les types de train du fichier existent déjà dans Supabase. +
+ {isCreated ? ( + ✅ Enregistré + ) : isMapped ? ( + 🔗 Associé + ) : ( + ❌ Non trouvé + )} + {t.nom} + {t.count} trains + + + + {!isCreated && ( + + )} +
+
+
+ )} + + {/* Onglet Lignes */} + {missingEntitiesTab === 'lignes' && ( +
+
+
+ {lignesManquantes.length} ligne(s) détectée(s) dont {unresolvedLignes} non enregistrée(s). +
+ {unresolvedLignes > 0 && ( + + )} +
+ +
+ + + + + + + + + + + + {lignesManquantes.length === 0 ? ( + + + + ) : ( + lignesManquantes.map((l) => { + const isCreated = l.created; + const isMapped = !!l.mappedLigneId; + return ( + + + + + + + + ); + }) + )} + +
StatutNom de la ligneFréquence fichierAssocier à une ligne existanteAction
+ Toutes les lignes du fichier existent déjà dans Supabase. +
+ {isCreated ? ( + ✅ Enregistrée + ) : isMapped ? ( + 🔗 Associée + ) : ( + ❌ Non trouvée + )} + {l.nom} + {l.count} trains + + + + {!isCreated && ( + + )} +
+
+
+ )} + + {/* Onglet Services Annuels */} + {missingEntitiesTab === 'services' && ( +
+
+
+ {missingServiceWindows.length} plage(s) de dates de circulation non associée(s) à un Service Annuel. +
+ setStep(3)} + > + Configurer dans l'Étape 3 + +
+ +
+ + + + + + + + + + {missingServiceWindows.map((w, idx) => ( + + + + + + ))} + +
Période de circulationNombre de trainsAction
{w.start} → {w.end}{(w.rows?.length ?? w.rowIndices?.length ?? 0)} train(s) + setStep(3)} + > + Gérer ce service + +
+
+
)}
-
- -
-
Gares non trouvées dans Supabase
-
Les gares ci-dessous sont référencées dans le fichier mais n'existent pas encore dans la base. Créez-les pour assurer la cohérence des données.
-
-
-
- - - - - - - - - - {garesManquantes.map((g) => ( - - - - - - ))} - -
StatutNom de la gareAction
{g.created ? ✅ Créée : ❌ Manquante}{g.nom}{!g.created && ( - - )}
-
-
- )} + ); + })()} {step === 3 && (
@@ -3834,7 +4624,7 @@ export default function ImportsPage() { Choix du Service Annuel
- garesManquantes.length > 0 ? setStep(2) : setStep(1)}> + (garesManquantes.length > 0 || typesManquants.length > 0 || lignesManquantes.length > 0) ? setStep(2) : setStep(1)}> Retour setStep(5)}> @@ -5031,17 +5821,38 @@ export default function ImportsPage() {

Aperçu et import final

- setStep(5)}>Retour + setStep(5)}>Retour {importing ? 'Import en cours...' : `Lancer l'import de ${previewSelected.length} ligne(s)`}
+ + {importing && importProgress && ( +
+
+ Importation en cours vers Supabase... + {importProgress.current} / {importProgress.total} ({importProgress.percent}%) +
+
+
+
+
+ Enregistrement en base de données : {importProgress.currentTrain} +
+
+ )} +
+ + @@ -5051,6 +5862,8 @@ export default function ImportsPage() { + + diff --git a/src/pages/horaires/ImportsPage.test.js b/src/pages/horaires/ImportsPage.test.js new file mode 100644 index 0000000..680c7c7 --- /dev/null +++ b/src/pages/horaires/ImportsPage.test.js @@ -0,0 +1,132 @@ +/** + * Tests unitaires pour la logique de pré-résolution et d'importation d'horaires + */ + +describe('Système de détection et de pré-résolution d\'entités manquantes avant import', () => { + const mockGares = [ + { id: 'g-paris', nom: 'Paris Gare de Lyon' }, + { id: 'g-lyon', nom: 'Lyon Part-Dieu' } + ]; + + const mockTypes = [ + { id: 't-tgv', nom: 'TGV INOUI' }, + { id: 't-ter', nom: 'TER' } + ]; + + const mockLignes = [ + { id: 'l-plm', nom: 'Ligne Paris - Marseille' } + ]; + + const mockServices = [ + { id: 'sa-2026', nom: 'SA 2026', date_debut: '2025-12-14', date_fin: '2026-12-12' } + ]; + + test('Détection correcte des gares, types et lignes manquants dans un fichier d\'import', () => { + const rows = [ + { + numero_train: '6601', + gare_depart_nom: 'Paris Gare de Lyon', + gare_arrivee_nom: 'Marseille Saint-Charles', // Manquante + type_train: 'TGV INOUI', + ligne_nom: 'Ligne Paris - Marseille', + gares_desservies: JSON.stringify([ + { gare_nom: 'Lyon Part-Dieu' }, + { gare_nom: 'Avignon TGV' } // Manquante + ]) + }, + { + numero_train: '8501', + gare_depart_nom: 'Marseille Saint-Charles', + gare_arrivee_nom: 'Nice Ville', // Manquante + type_train: 'OUIGO', // Manquant + ligne_nom: 'Ligne Côte d\'Azur' // Manquante + } + ]; + + // Simulation de la détection + const missingGares = new Set(); + const missingTypes = new Set(); + const missingLignes = new Set(); + + const garesMap = new Set(mockGares.map(g => g.nom.toLowerCase())); + const typesMap = new Set(mockTypes.map(t => t.nom.toLowerCase())); + const lignesMap = new Set(mockLignes.map(l => l.nom.toLowerCase())); + + rows.forEach(r => { + if (r.gare_depart_nom && !garesMap.has(r.gare_depart_nom.toLowerCase())) missingGares.add(r.gare_depart_nom); + if (r.gare_arrivee_nom && !garesMap.has(r.gare_arrivee_nom.toLowerCase())) missingGares.add(r.gare_arrivee_nom); + if (r.type_train && !typesMap.has(r.type_train.toLowerCase())) missingTypes.add(r.type_train); + if (r.ligne_nom && !lignesMap.has(r.ligne_nom.toLowerCase())) missingLignes.add(r.ligne_nom); + if (r.gares_desservies) { + JSON.parse(r.gares_desservies).forEach(s => { + if (s.gare_nom && !garesMap.has(s.gare_nom.toLowerCase())) missingGares.add(s.gare_nom); + }); + } + }); + + expect(Array.from(missingGares)).toEqual(['Marseille Saint-Charles', 'Avignon TGV', 'Nice Ville']); + expect(Array.from(missingTypes)).toEqual(['OUIGO']); + expect(Array.from(missingLignes)).toEqual(['Ligne Côte d\'Azur']); + }); + + test('Propagation immédiate des IDs nouvellement créés dans les lignes importées', () => { + let rows = [ + { + numero_train: '6601', + gare_depart_id: null, + gare_depart_nom: 'Marseille Saint-Charles', + gare_arrivee_id: null, + gare_arrivee_nom: 'Paris Gare de Lyon', + type_train_id: null, + type_train: 'OUIGO', + ligne_id: null, + ligne_nom: 'Ligne Côte d\'Azur', + gares_desservies: [ + { gare_id: null, gare_nom: 'Avignon TGV', heure_arrivee: '10:00', heure_depart: '10:02' } + ] + } + ]; + + // 1. Simulation création de la gare 'Marseille Saint-Charles' + const newMarseilleId = 'g-marseille-uuid'; + rows = rows.map(r => { + const isDep = r.gare_depart_nom === 'Marseille Saint-Charles'; + const isArr = r.gare_arrivee_nom === 'Marseille Saint-Charles'; + return { + ...r, + ...(isDep ? { gare_depart_id: newMarseilleId } : {}), + ...(isArr ? { gare_arrivee_id: newMarseilleId } : {}) + }; + }); + + // 2. Simulation création du type de train 'OUIGO' + const newOuigoId = 't-ouigo-uuid'; + rows = rows.map(r => { + if (r.type_train === 'OUIGO') { + return { ...r, type_train_id: newOuigoId }; + } + return r; + }); + + // 3. Simulation création de la ligne 'Ligne Côte d\'Azur' + const newLigneId = 'l-cda-uuid'; + rows = rows.map(r => { + if (r.ligne_nom === 'Ligne Côte d\'Azur') { + return { ...r, ligne_id: newLigneId }; + } + return r; + }); + + // 4. Simulation création de l'arrêt intermédiaire 'Avignon TGV' + const newAvignonId = 'g-avignon-uuid'; + rows = rows.map(r => ({ + ...r, + gares_desservies: r.gares_desservies.map(s => (s.gare_nom === 'Avignon TGV' ? { ...s, gare_id: newAvignonId } : s)) + })); + + expect(rows[0].gare_depart_id).toBe('g-marseille-uuid'); + expect(rows[0].type_train_id).toBe('t-ouigo-uuid'); + expect(rows[0].ligne_id).toBe('l-cda-uuid'); + expect(rows[0].gares_desservies[0].gare_id).toBe('g-avignon-uuid'); + }); +}); diff --git a/src/pages/login/Login.css b/src/pages/login/Login.css index 388025a..917d6a5 100644 --- a/src/pages/login/Login.css +++ b/src/pages/login/Login.css @@ -150,6 +150,14 @@ margin-top: 14px; display: flex; justify-content: flex-end; + align-items: center; + gap: 10px; +} + +.admin-login-separator { + color: #94a3b8; + font-size: 13px; + user-select: none; } .admin-login-link { diff --git a/src/pages/login/Login.js b/src/pages/login/Login.js index 3d45d07..9455f8e 100644 --- a/src/pages/login/Login.js +++ b/src/pages/login/Login.js @@ -1,5 +1,6 @@ import React, { useRef, useState, useEffect } from 'react'; import supabase, { isSupabaseConfigured } from '../../supabaseClient'; +import { buildUserProfileFromAuth } from '../../utils/userUtils'; import './Login.css'; @@ -60,39 +61,53 @@ export default function Login({ onLogin }) { return; } - // Récupérer le profil métier depuis la table utilisateurs - const { data: profile, error: profileError } = await supabase - .from('utilisateurs') - .select('id, email, nom, prenom, role, actif, region_admin, region_id, region_nom, permissions') - .eq('email', email) - .maybeSingle(); + // Récupération facultative du profil métier depuis la table 'utilisateurs' s'il existe + let dbProfile = null; + try { + const { data: profile } = await supabase + .from('utilisateurs') + .select('id, email, nom, prenom, role, actif, region_admin, region_id, region_nom, permissions, photo_url') + .eq('email', email) + .maybeSingle(); - if (profileError || !profile) { - setError("Profil utilisateur introuvable dans la base de données."); - await supabase.auth.signOut(); - setLoading(false); - return; + if (profile) { + dbProfile = profile; + } + } catch (e) { + // En l'absence de table ou erreur RLS, les métadonnées Supabase Auth prennent le relais + console.warn('[Login] Profil DB non disponible, utilisation des métadonnées Supabase Auth'); } - if (profile.actif === false) { + // Construit le profil et les permissions directement depuis Supabase Auth + const user = buildUserProfileFromAuth(authData.user, dbProfile); + + if (user.actif === false) { setError("Ce compte est désactivé."); await supabase.auth.signOut(); setLoading(false); return; } - const user = { - id: profile.id, - email: profile.email, - nom: profile.nom, - prenom: profile.prenom, - name: [profile.nom, profile.prenom].filter(Boolean).join(' ').trim() || profile.email, - role: profile.role, - permissions: profile.permissions || {}, - region_admin: profile.region_admin === true, - region_id: profile.region_id || null, - region_nom: profile.region_nom || null - }; + // Synchroniser le profil dans user_metadata de Supabase Auth si absent pour pérennité sans table + try { + const currentMeta = authData.user.user_metadata || {}; + if (!currentMeta.role || !currentMeta.permissions) { + await supabase.auth.updateUser({ + data: { + role: user.role, + permissions: user.permissions, + nom: user.nom, + prenom: user.prenom, + name: user.name, + region_admin: user.region_admin, + region_id: user.region_id, + region_nom: user.region_nom + } + }); + } + } catch (metaErr) { + // Ignorer silencieusement si échec de mise à jour des métadonnées + } try { window.localStorage.setItem('app_user', JSON.stringify(user)); } catch (_) {} onLogin(user); @@ -168,6 +183,18 @@ export default function Login({ onLogin }) { diff --git a/src/pages/reinitialiser-mot-de-passe/ReinitialiserMotDePasse.css b/src/pages/reinitialiser-mot-de-passe/ReinitialiserMotDePasse.css new file mode 100644 index 0000000..e66645f --- /dev/null +++ b/src/pages/reinitialiser-mot-de-passe/ReinitialiserMotDePasse.css @@ -0,0 +1,81 @@ +/* Styles pour la page de réinitialisation de mot de passe */ +.reset-password-shell { + grid-template-columns: 1fr minmax(380px, 480px); +} + +.reset-password-panel { + display: flex; + flex-direction: column; +} + +.reset-password-info-card { + border: 1px solid #d9e2ec; + border-radius: 10px; + padding: 16px; + background: #f8fbff; + display: flex; + flex-direction: column; + gap: 8px; +} + +.reset-password-info-card h3 { + margin: 0; + font-size: 16px; + color: #0f172a; +} + +.reset-password-info-card p { + margin: 0; + color: #475569; + font-size: 14px; + line-height: 1.5; +} + +.reset-password-actions { + margin-top: 10px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.reset-password-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.reset-password-confirmation { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.reset-password-countdown { + font-size: 14px; + color: #5a6577; +} + +@media (max-width: 980px) { + .reset-password-shell { + grid-template-columns: 1fr; + } +} + +/* Dark mode */ +body.dark-mode .reset-password-info-card, +[data-theme="dark"] .reset-password-info-card { + background: #0e1726; + border-color: #243247; +} + +body.dark-mode .reset-password-info-card h3, +[data-theme="dark"] .reset-password-info-card h3 { + color: #f8fafc; +} + +body.dark-mode .reset-password-info-card p, +[data-theme="dark"] .reset-password-info-card p { + color: #cbd5e1; +} diff --git a/src/pages/reinitialiser-mot-de-passe/ReinitialiserMotDePasse.js b/src/pages/reinitialiser-mot-de-passe/ReinitialiserMotDePasse.js new file mode 100644 index 0000000..12b4a9f --- /dev/null +++ b/src/pages/reinitialiser-mot-de-passe/ReinitialiserMotDePasse.js @@ -0,0 +1,362 @@ +import React, { useState, useEffect, useRef } from 'react'; +import supabase, { isSupabaseConfigured } from '../../supabaseClient'; +import '../login/Login.css'; +import './ReinitialiserMotDePasse.css'; + +export default function ReinitialiserMotDePasse() { + const emailRef = useRef(null); + const passwordRef = useRef(null); + const confirmPasswordRef = useRef(null); + + // Étape du flow : + // 'request': formulaire d'envoi du mail de réinitialisation + // 'sent': email envoyé, confirmation affichée + // 'reset': saisie du nouveau mot de passe (suite au clic sur le lien dans l'email) + // 'success': mot de passe mis à jour avec succès + const [mode, setMode] = useState('request'); + const [targetEmail, setTargetEmail] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [loading, setLoading] = useState(false); + const [countdown, setCountdown] = useState(5); + + // Détection de l'accès via le lien de réinitialisation (recovery token ou session) + useEffect(() => { + // 1. Vérifier si l'URL contient un hash de récupération (#access_token=...&type=recovery) + const hash = window.location.hash || ''; + const search = window.location.search || ''; + + const isRecoveryHash = hash.includes('type=recovery') || hash.includes('access_token='); + const isRecoverySearch = search.includes('type=recovery'); + + if (isRecoveryHash || isRecoverySearch) { + setMode('reset'); + } + + // 2. Écouter les événements Auth de Supabase (ex: PASSWORD_RECOVERY) + const { data: authListener } = supabase.auth.onAuthStateChange(async (event, session) => { + if (event === 'PASSWORD_RECOVERY') { + setMode('reset'); + } + }); + + // 3. Préremplir l'email si passé en paramètre d'URL (?email=...) + try { + const params = new URLSearchParams(window.location.search); + const emailParam = params.get('email'); + if (emailParam) { + setTargetEmail(emailParam); + if (emailRef.current) { + emailRef.current.value = emailParam; + } + } + } catch (_) {} + + return () => { + if (authListener?.subscription) { + authListener.subscription.unsubscribe(); + } + }; + }, []); + + // Compte à rebours avant redirection après mise à jour réussie + useEffect(() => { + if (mode !== 'success') return undefined; + + const intervalId = window.setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + window.clearInterval(intervalId); + window.location.assign('/'); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => window.clearInterval(intervalId); + }, [mode]); + + // Envoi de l'email de réinitialisation via Supabase + const handleRequestReset = async (e) => { + if (e && e.preventDefault) e.preventDefault(); + setError(''); + setSuccess(''); + + const email = emailRef.current ? String(emailRef.current.value || '').trim().toLowerCase() : targetEmail.trim().toLowerCase(); + + if (!email) { + setError('Veuillez renseigner votre adresse email.'); + return; + } + + if (!isSupabaseConfigured) { + setError('Supabase n\'est pas configuré. Impossible d\'envoyer le mail de réinitialisation.'); + return; + } + + setLoading(true); + + try { + const redirectTo = `${window.location.origin}/reinitialiser-mot-de-passe`; + + const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo + }); + + if (resetError) { + setError(`Erreur lors de l'envoi de l'email : ${resetError.message}`); + setLoading(false); + return; + } + + setTargetEmail(email); + setMode('sent'); + setSuccess(`Un email contenant un lien de réinitialisation a été envoyé à ${email}.`); + } catch (err) { + setError(`Erreur inattendue : ${err?.message || 'inconnue'}`); + } finally { + setLoading(false); + } + }; + + // Mise à jour effective du mot de passe + const handleUpdatePassword = async (e) => { + if (e && e.preventDefault) e.preventDefault(); + setError(''); + setSuccess(''); + + const password = passwordRef.current ? String(passwordRef.current.value || '') : ''; + const confirmPassword = confirmPasswordRef.current ? String(confirmPasswordRef.current.value || '') : ''; + + if (!password || !confirmPassword) { + setError('Veuillez saisir et confirmer votre nouveau mot de passe.'); + return; + } + + if (password !== confirmPassword) { + setError('Les mots de passe ne correspondent pas.'); + return; + } + + if (password.length < 8) { + setError('Le mot de passe doit comporter au moins 8 caractères.'); + return; + } + + setLoading(true); + + try { + const { data, error: updateError } = await supabase.auth.updateUser({ + password: password + }); + + if (updateError) { + setError(`Erreur lors de la mise à jour du mot de passe : ${updateError.message}`); + setLoading(false); + return; + } + + // Mot de passe mis à jour avec succès + setMode('success'); + setCountdown(5); + } catch (err) { + setError(`Erreur inattendue : ${err?.message || 'inconnue'}`); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + +
+
+

+ {mode === 'reset' && 'Définir un nouveau mot de passe'} + {mode === 'sent' && 'Email envoyé'} + {mode === 'success' && 'Mot de passe modifié'} + {mode === 'request' && 'Mot de passe oublié ?'} +

+

+ {mode === 'reset' && 'Saisissez votre nouveau mot de passe sécurisé pour finaliser la procédure.'} + {mode === 'sent' && 'Vérifiez votre boîte de réception pour continuer.'} + {mode === 'success' && 'Votre nouveau mot de passe est immédiatement actif.'} + {mode === 'request' && 'Saisissez votre adresse email pour recevoir un lien de réinitialisation.'} +

+
+ + {/* Mode 1 : Demande d'envoi du lien */} + {mode === 'request' && ( +
+
+ + + + {error && ( + + Erreur + {error} + + )} + +
+
+ + {loading ? 'Envoi en cours...' : 'Envoyer le lien'} + + window.location.assign('/')} + > + Retour + +
+
+ {loading && } +
+
+ + +
+ + )} + + {/* Mode 2 : Email envoyé */} + {mode === 'sent' && ( +
+
+

Vérifiez votre messagerie

+

+ Un email avec un lien de réinitialisation sécurisé vient d’être envoyé à {targetEmail}. +

+

+ Ce lien est valable pour une durée limitée. Pensez à vérifier vos courriers indésirables (spams) si vous ne le recevez pas d'ici quelques instants. +

+
+ +
+ window.location.assign('/')} + > + Retour à la connexion + + setMode('request')} + > + Renvoyer un email + +
+
+ )} + + {/* Mode 3 : Saisie du nouveau mot de passe */} + {mode === 'reset' && ( +
+
+ + + + + + + {error && ( + + Erreur + {error} + + )} + +
+
+ + {loading ? 'Modification...' : 'Enregistrer le mot de passe'} + +
+
+ {loading && } +
+
+
+ + )} + + {/* Mode 4 : Succès */} + {mode === 'success' && ( +
+ + Mot de passe réinitialisé ! + + Votre mot de passe a été modifié avec succès. Vous pouvez désormais vous connecter. + + + +

+ Redirection automatique vers la page de connexion dans {countdown}s... +

+ +
+ window.location.assign('/')} + > + Se connecter immédiatement + +
+
+ )} + +
+
+
+ ); +} diff --git a/src/utils/userUtils.js b/src/utils/userUtils.js index a790c6c..4ea8f9b 100644 --- a/src/utils/userUtils.js +++ b/src/utils/userUtils.js @@ -1,254 +1,299 @@ -/** - * Utilitaires RBAC: matrice module/action + overrides utilisateur. - */ - -export const CRUD_ACTIONS = ['read', 'create', 'update', 'delete']; - -export const RBAC_MODULES = [ - 'dashboard', - 'gares', - 'materiel_roulant', - 'horaires', - 'fiches_horaires', - 'lignes', - 'services', - 'quais', - 'informations', - 'perturbations', - 'travaux', - 'menu', - 'pages', - 'tarifs', - 'messagerie', - 'parametres', - 'utilisateurs_global', - 'annonces_sonores', - 'systeme_maj' -]; - -const denyAll = Object.fromEntries(CRUD_ACTIONS.map((a) => [a, false])); - -function fullAccess() { - return Object.fromEntries(CRUD_ACTIONS.map((a) => [a, true])); -} - -function readOnly() { - return { read: true, create: false, update: false, delete: false }; -} - -export const ROLE_PERMISSION_TEMPLATES = { - admin: Object.fromEntries(RBAC_MODULES.map((m) => [m, fullAccess()])), - gestionnaire: { - dashboard: fullAccess(), - gares: fullAccess(), - materiel_roulant: fullAccess(), - horaires: fullAccess(), - fiches_horaires: fullAccess(), - lignes: fullAccess(), - services: fullAccess(), - quais: fullAccess(), - informations: fullAccess(), - perturbations: fullAccess(), - travaux: fullAccess(), - menu: fullAccess(), - pages: denyAll, - tarifs: fullAccess(), - messagerie: fullAccess(), - parametres: fullAccess(), - utilisateurs_global: fullAccess(), - annonces_sonores: fullAccess() - }, - exploitant: { - dashboard: readOnly(), - gares: fullAccess(), - materiel_roulant: fullAccess(), - horaires: fullAccess(), - fiches_horaires: fullAccess(), - lignes: fullAccess(), - services: fullAccess(), - quais: fullAccess(), - informations: fullAccess(), - perturbations: fullAccess(), - travaux: fullAccess(), - menu: fullAccess(), - pages: denyAll, - tarifs: fullAccess(), - messagerie: fullAccess(), - parametres: { read: true, create: false, update: true, delete: false }, - utilisateurs_global: denyAll, - annonces_sonores: fullAccess() - }, - maintenance: { - dashboard: readOnly(), - gares: denyAll, - materiel_roulant: denyAll, - horaires: readOnly(), - lignes: readOnly(), - services: readOnly(), - quais: denyAll, - informations: readOnly(), - perturbations: readOnly(), - travaux: readOnly(), - menu: denyAll, - pages: denyAll, - tarifs: denyAll, - messagerie: readOnly(), - parametres: denyAll, - utilisateurs_global: denyAll, - annonces_sonores: denyAll - }, - regulateur: { - dashboard: readOnly(), - gares: readOnly(), - materiel_roulant: readOnly(), - horaires: fullAccess(), - lignes: fullAccess(), - services: fullAccess(), - quais: fullAccess(), - informations: fullAccess(), - perturbations: fullAccess(), - travaux: fullAccess(), - menu: denyAll, - pages: fullAccess(), - tarifs: readOnly(), - messagerie: fullAccess(), - parametres: readOnly(), - utilisateurs_global: denyAll, - annonces_sonores: fullAccess() - }, - utilisateur: { - dashboard: readOnly(), - gares: denyAll, - materiel_roulant: denyAll, - horaires: denyAll, - lignes: denyAll, - services: denyAll, - quais: denyAll, - informations: readOnly(), - perturbations: denyAll, - travaux: denyAll, - menu: denyAll, - pages: denyAll, - tarifs: denyAll, - messagerie: readOnly(), - parametres: denyAll, - utilisateurs_global: denyAll, - annonces_sonores: denyAll - } -}; - - -/** - * Récupère l'utilisateur connecté depuis le localStorage - */ -export function getCurrentUser() { - try { - const raw = window.localStorage.getItem('app_user'); - return raw ? JSON.parse(raw) : null; - } catch (e) { - return null; - } -} - -export function isExploitant(user) { - if (!user) return false; - return user.role === 'exploitant'; -} - -/** - * Vérifie si l'utilisateur est un exploitant régional (filtré par région) - * Un exploitant régional a le rôle "exploitant" ET region_admin = true - */ -export function isRegionFiltered(user) { - if (!user) return false; - return isExploitant(user) && !!user.region_id; -} - -/** - * Retourne l'ID de la région de l'utilisateur (si applicable) - */ -export function getUserRegionId(user) { - if (!user) return null; - return user.region_id || null; -} - -/** - * Retourne le scope régional à appliquer aux requêtes (null = accès global). - */ -export function getScopedRegionId(user) { - return isRegionFiltered(user) ? getUserRegionId(user) : null; -} - -/** - * Vérifie si l'utilisateur est un administrateur - */ -export function isAdmin(user) { - if (!user) return false; - return user.role === 'admin'; -} - -/** - * Vérifie si l'utilisateur est un gestionnaire - */ -export function isGestionnaire(user) { - if (!user) return false; - return user.role === 'gestionnaire'; -} - -function normalizeModulePermissions(value, fallback) { - // Compat: ancien format bool => applique a toutes les actions. - if (typeof value === 'boolean') { - return Object.fromEntries(CRUD_ACTIONS.map((a) => [a, value])); - } - const base = { ...(fallback || denyAll) }; - if (!value || typeof value !== 'object') return base; - CRUD_ACTIONS.forEach((a) => { - if (Object.prototype.hasOwnProperty.call(value, a)) base[a] = !!value[a]; - }); - return base; -} - -export function getEffectivePermissions(user) { - if (!user) return {}; - - // Si pas de permissions définies (mode permissions-only strict) - if (!user.permissions) { - return Object.fromEntries(RBAC_MODULES.map(m => [m, { ...denyAll }])); - } - - // 1. Template par défaut selon le rôle - const template = ROLE_PERMISSION_TEMPLATES[user.role] || ROLE_PERMISSION_TEMPLATES.utilisateur; - - // 2. Overrides personnalisés de l'utilisateur - const custom = (user.permissions && typeof user.permissions === 'object') ? user.permissions : {}; - - const result = {}; - - RBAC_MODULES.forEach((moduleKey) => { - // On fusionne le template et les overrides (si définis) - const templateValue = template[moduleKey] || denyAll; - const customValue = custom[moduleKey]; - - if (customValue !== undefined) { - result[moduleKey] = normalizeModulePermissions(customValue, templateValue); - } else { - result[moduleKey] = normalizeModulePermissions(templateValue, denyAll); - } - }); - - return result; -} - -export function canPerform(user, moduleKey, action) { - if (!user || !moduleKey || !action) return false; - const perms = getEffectivePermissions(user); - return !!(perms[moduleKey] && perms[moduleKey][action]); -} - -/** - * Vérifie si l'utilisateur a accès à une section donnée - * Mode permissions-only: l'accès dépend uniquement de permissions[module].read. - */ -export function hasAccessTo(user, section) { - return canPerform(user, section, 'read'); -} \ No newline at end of file +/** + * Utilitaires RBAC: matrice module/action + overrides utilisateur. + */ + +export const CRUD_ACTIONS = ['read', 'create', 'update', 'delete']; + +export const RBAC_MODULES = [ + 'dashboard', + 'gares', + 'materiel_roulant', + 'horaires', + 'fiches_horaires', + 'lignes', + 'services', + 'quais', + 'informations', + 'perturbations', + 'travaux', + 'menu', + 'pages', + 'tarifs', + 'messagerie', + 'parametres', + 'utilisateurs_global', + 'annonces_sonores', + 'systeme_maj' +]; + +const denyAll = Object.fromEntries(CRUD_ACTIONS.map((a) => [a, false])); + +function fullAccess() { + return Object.fromEntries(CRUD_ACTIONS.map((a) => [a, true])); +} + +function readOnly() { + return { read: true, create: false, update: false, delete: false }; +} + +export const ROLE_PERMISSION_TEMPLATES = { + admin: Object.fromEntries(RBAC_MODULES.map((m) => [m, fullAccess()])), + gestionnaire: { + dashboard: fullAccess(), + gares: fullAccess(), + materiel_roulant: fullAccess(), + horaires: fullAccess(), + fiches_horaires: fullAccess(), + lignes: fullAccess(), + services: fullAccess(), + quais: fullAccess(), + informations: fullAccess(), + perturbations: fullAccess(), + travaux: fullAccess(), + menu: fullAccess(), + pages: denyAll, + tarifs: fullAccess(), + messagerie: fullAccess(), + parametres: fullAccess(), + utilisateurs_global: fullAccess(), + annonces_sonores: fullAccess() + }, + exploitant: { + dashboard: readOnly(), + gares: fullAccess(), + materiel_roulant: fullAccess(), + horaires: fullAccess(), + fiches_horaires: fullAccess(), + lignes: fullAccess(), + services: fullAccess(), + quais: fullAccess(), + informations: fullAccess(), + perturbations: fullAccess(), + travaux: fullAccess(), + menu: fullAccess(), + pages: denyAll, + tarifs: fullAccess(), + messagerie: fullAccess(), + parametres: { read: true, create: false, update: true, delete: false }, + utilisateurs_global: denyAll, + annonces_sonores: fullAccess() + }, + maintenance: { + dashboard: readOnly(), + gares: denyAll, + materiel_roulant: denyAll, + horaires: readOnly(), + lignes: readOnly(), + services: readOnly(), + quais: denyAll, + informations: readOnly(), + perturbations: readOnly(), + travaux: readOnly(), + menu: denyAll, + pages: denyAll, + tarifs: denyAll, + messagerie: readOnly(), + parametres: denyAll, + utilisateurs_global: denyAll, + annonces_sonores: denyAll + }, + regulateur: { + dashboard: readOnly(), + gares: readOnly(), + materiel_roulant: readOnly(), + horaires: fullAccess(), + lignes: fullAccess(), + services: fullAccess(), + quais: fullAccess(), + informations: fullAccess(), + perturbations: fullAccess(), + travaux: fullAccess(), + menu: denyAll, + pages: fullAccess(), + tarifs: readOnly(), + messagerie: fullAccess(), + parametres: readOnly(), + utilisateurs_global: denyAll, + annonces_sonores: fullAccess() + }, + utilisateur: { + dashboard: readOnly(), + gares: denyAll, + materiel_roulant: denyAll, + horaires: denyAll, + lignes: denyAll, + services: denyAll, + quais: denyAll, + informations: readOnly(), + perturbations: denyAll, + travaux: denyAll, + menu: denyAll, + pages: denyAll, + tarifs: denyAll, + messagerie: readOnly(), + parametres: denyAll, + utilisateurs_global: denyAll, + annonces_sonores: denyAll + } +}; + + +/** + * Récupère l'utilisateur connecté depuis le localStorage + */ +export function getCurrentUser() { + try { + const raw = window.localStorage.getItem('app_user'); + return raw ? JSON.parse(raw) : null; + } catch (e) { + return null; + } +} + +export function isExploitant(user) { + if (!user) return false; + return user.role === 'exploitant'; +} + +/** + * Vérifie si l'utilisateur est un exploitant régional (filtré par région) + * Un exploitant régional a le rôle "exploitant" ET region_admin = true + */ +export function isRegionFiltered(user) { + if (!user) return false; + return isExploitant(user) && !!user.region_id; +} + +/** + * Retourne l'ID de la région de l'utilisateur (si applicable) + */ +export function getUserRegionId(user) { + if (!user) return null; + return user.region_id || null; +} + +/** + * Retourne le scope régional à appliquer aux requêtes (null = accès global). + */ +export function getScopedRegionId(user) { + return isRegionFiltered(user) ? getUserRegionId(user) : null; +} + +/** + * Vérifie si l'utilisateur est un administrateur + */ +export function isAdmin(user) { + if (!user) return false; + return user.role === 'admin'; +} + +/** + * Vérifie si l'utilisateur est un gestionnaire + */ +export function isGestionnaire(user) { + if (!user) return false; + return user.role === 'gestionnaire'; +} + +function normalizeModulePermissions(value, fallback) { + // Compat: ancien format bool => applique a toutes les actions. + if (typeof value === 'boolean') { + return Object.fromEntries(CRUD_ACTIONS.map((a) => [a, value])); + } + const base = { ...(fallback || denyAll) }; + if (!value || typeof value !== 'object') return base; + CRUD_ACTIONS.forEach((a) => { + if (Object.prototype.hasOwnProperty.call(value, a)) base[a] = !!value[a]; + }); + return base; +} + +export function getEffectivePermissions(user) { + if (!user) return {}; + + // Si pas de permissions définies (mode permissions-only strict) + if (!user.permissions) { + return Object.fromEntries(RBAC_MODULES.map(m => [m, { ...denyAll }])); + } + + // 1. Template par défaut selon le rôle + const template = ROLE_PERMISSION_TEMPLATES[user.role] || ROLE_PERMISSION_TEMPLATES.utilisateur; + + // 2. Overrides personnalisés de l'utilisateur + const custom = (user.permissions && typeof user.permissions === 'object') ? user.permissions : {}; + + const result = {}; + + RBAC_MODULES.forEach((moduleKey) => { + // On fusionne le template et les overrides (si définis) + const templateValue = template[moduleKey] || denyAll; + const customValue = custom[moduleKey]; + + if (customValue !== undefined) { + result[moduleKey] = normalizeModulePermissions(customValue, templateValue); + } else { + result[moduleKey] = normalizeModulePermissions(templateValue, denyAll); + } + }); + + return result; +} + +export function canPerform(user, moduleKey, action) { + if (!user || !moduleKey || !action) return false; + const perms = getEffectivePermissions(user); + return !!(perms[moduleKey] && perms[moduleKey][action]); +} + +/** + * Vérifie si l'utilisateur a accès à une section donnée + * Mode permissions-only: l'accès dépend uniquement de permissions[module].read. + */ +export function hasAccessTo(user, section) { + return canPerform(user, section, 'read'); +} + +/** + * Construit un profil utilisateur complet et ses permissions à partir d'un objet Supabase Auth (GoTrue). + * Utilise en priorité les métadonnées de l'utilisateur (user_metadata / app_metadata). + * Cela permet de gérer entièrement les profils et les permissions sans nécessiter de table personnalisée. + */ +export function buildUserProfileFromAuth(supabaseUser, dbProfile = null) { + if (!supabaseUser) return null; + + const metadata = { + ...(supabaseUser.app_metadata || {}), + ...(supabaseUser.user_metadata || {}) + }; + + const role = dbProfile?.role || metadata.role || 'admin'; + const nom = dbProfile?.nom || metadata.nom || metadata.last_name || ''; + const prenom = dbProfile?.prenom || metadata.prenom || metadata.first_name || ''; + + // Déterminer les permissions + let permissions = dbProfile?.permissions || metadata.permissions; + if (!permissions || typeof permissions !== 'object' || Object.keys(permissions).length === 0) { + // Gabarit par défaut selon le rôle + permissions = ROLE_PERMISSION_TEMPLATES[role] || ROLE_PERMISSION_TEMPLATES.admin; + } + + const email = (supabaseUser.email || dbProfile?.email || '').trim().toLowerCase(); + const emailPrefix = email ? email.split('@')[0] : 'Utilisateur'; + const computedName = [nom, prenom].filter(Boolean).join(' ').trim(); + const name = computedName || metadata.name || metadata.full_name || emailPrefix; + + return { + id: supabaseUser.id || dbProfile?.id, + email: email, + nom: nom, + prenom: prenom, + name: name, + role: role, + permissions: permissions, + region_admin: dbProfile ? dbProfile.region_admin === true : metadata.region_admin === true, + region_id: dbProfile?.region_id || metadata.region_id || null, + region_nom: dbProfile?.region_nom || metadata.region_nom || null, + photo_url: dbProfile?.photo_url || metadata.photo_url || null, + actif: dbProfile ? dbProfile.actif !== false : metadata.actif !== false + }; +} \ No newline at end of file diff --git a/src/utils/userUtils.test.js b/src/utils/userUtils.test.js index e75d0b0..9d29598 100644 --- a/src/utils/userUtils.test.js +++ b/src/utils/userUtils.test.js @@ -48,3 +48,63 @@ describe('RBAC permissions-only', () => { }); }); +describe('buildUserProfileFromAuth', () => { + const { buildUserProfileFromAuth } = require('./userUtils'); + + test('construit le profil depuis user_metadata sans table DB', () => { + const authUser = { + id: 'uuid-1234', + email: 'agent@sncf.fr', + user_metadata: { + nom: 'Martin', + prenom: 'Paul', + role: 'gestionnaire', + permissions: { + gares: { read: true, create: true, update: true, delete: false } + } + } + }; + + const profile = buildUserProfileFromAuth(authUser); + + expect(profile.id).toBe('uuid-1234'); + expect(profile.email).toBe('agent@sncf.fr'); + expect(profile.nom).toBe('Martin'); + expect(profile.prenom).toBe('Paul'); + expect(profile.name).toBe('Martin Paul'); + expect(profile.role).toBe('gestionnaire'); + expect(profile.permissions.gares.read).toBe(true); + expect(profile.actif).toBe(true); + }); + + test('applique un rôle admin et ses permissions complètes par défaut si métadonnées absentes', () => { + const authUser = { + id: 'uuid-admin', + email: 'admin@ferrovia.fr', + user_metadata: {} + }; + + const profile = buildUserProfileFromAuth(authUser); + + expect(profile.id).toBe('uuid-admin'); + expect(profile.email).toBe('admin@ferrovia.fr'); + expect(profile.role).toBe('admin'); + expect(profile.permissions.dashboard.read).toBe(true); + expect(profile.permissions.gares.create).toBe(true); + }); + + test('gère le cas où l\'utilisateur est désactivé', () => { + const authUser = { + id: 'uuid-inactif', + email: 'inactif@ferrovia.fr', + user_metadata: { + actif: false + } + }; + + const profile = buildUserProfileFromAuth(authUser); + expect(profile.actif).toBe(false); + }); +}); + + diff --git a/supabase/EMAIL_SETUP_SELF_HOSTED.md b/supabase/EMAIL_SETUP_SELF_HOSTED.md new file mode 100644 index 0000000..65b9e86 --- /dev/null +++ b/supabase/EMAIL_SETUP_SELF_HOSTED.md @@ -0,0 +1,152 @@ +# Guide de Configuration : Authentification & Envoi d'Emails Supabase Self-Hosted + +Ce document détaille la configuration complète pour : +1. **Gérer les profils et permissions sans table dédiée** via Supabase Auth (`user_metadata`). +2. **Configurer l'envoi d'emails SMTP** sur une instance Supabase Self-Hosted. +3. **Appliquer le template HTML de réinitialisation de mot de passe**. + +--- + +## 1. Stockage des Profils et Permissions sans Table Dédiée + +Supabase Auth intègre nativement un champ JSONB nommé `raw_user_meta_data` dans la table système `auth.users`. +L'application Ferrovia Panel utilise désormais ce champ pour stocker les profils utilisateurs et leurs permissions RBAC sans nécessiter de table `public.utilisateurs`. + +### Structure des Métadonnées Utilisateur (`user_metadata`) +```json +{ + "nom": "Dupont", + "prenom": "Jean", + "name": "Jean Dupont", + "role": "admin", + "permissions": { + "dashboard": { "read": true, "create": true, "update": true, "delete": true }, + "gares": { "read": true, "create": true, "update": true, "delete": true }, + "horaires": { "read": true, "create": true, "update": true, "delete": true } + }, + "region_admin": false, + "region_id": null, + "region_nom": null, + "actif": true +} +``` + +### Consultation et Modification directe depuis Supabase (SQL) +Si vous souhaitez attribuer le rôle administrateur ou des permissions personnalisées à un utilisateur directement en base de données : + +```sql +-- Mettre à jour les métadonnées d'un utilisateur existant : +UPDATE auth.users +SET raw_user_meta_data = jsonb_build_object( + 'nom', 'Administrateur', + 'prenom', 'Principal', + 'name', 'Principal Administrateur', + 'role', 'admin', + 'actif', true, + 'permissions', '{ + "dashboard": {"read": true, "create": true, "update": true, "delete": true}, + "gares": {"read": true, "create": true, "update": true, "delete": true}, + "materiel_roulant": {"read": true, "create": true, "update": true, "delete": true}, + "horaires": {"read": true, "create": true, "update": true, "delete": true}, + "fiches_horaires": {"read": true, "create": true, "update": true, "delete": true}, + "lignes": {"read": true, "create": true, "update": true, "delete": true}, + "services": {"read": true, "create": true, "update": true, "delete": true}, + "quais": {"read": true, "create": true, "update": true, "delete": true}, + "informations": {"read": true, "create": true, "update": true, "delete": true}, + "perturbations": {"read": true, "create": true, "update": true, "delete": true}, + "travaux": {"read": true, "create": true, "update": true, "delete": true}, + "menu": {"read": true, "create": true, "update": true, "delete": true}, + "pages": {"read": true, "create": true, "update": true, "delete": true}, + "tarifs": {"read": true, "create": true, "update": true, "delete": true}, + "messagerie": {"read": true, "create": true, "update": true, "delete": true}, + "parametres": {"read": true, "create": true, "update": true, "delete": true}, + "utilisateurs_global": {"read": true, "create": true, "update": true, "delete": true}, + "annonces_sonores": {"read": true, "create": true, "update": true, "delete": true}, + "systeme_maj": {"read": true, "create": true, "update": true, "delete": true} + }'::jsonb +) +WHERE email = 'votre-email@domaine.com'; +``` + +--- + +## 2. Configuration SMTP sur Supabase Self-Hosted (Docker) + +Sur votre serveur hébergeant Supabase (ex: avec Docker Compose), éditez le fichier `.env` du dossier Supabase. + +### Variables d'environnement pour l'envoi de mail (GoTrue / Auth) + +```env +## Configuration Générale du Site +SITE_URL=https://votre-domaine.fr +ADDITIONAL_REDIRECT_URLS=https://votre-domaine.fr/reinitialiser-mot-de-passe,http://localhost:3000/reinitialiser-mot-de-passe + +## Activation du Serveur SMTP +GOTRUE_SMTP_HOST=smtp.votre-fournisseur.com +GOTRUE_SMTP_PORT=587 +GOTRUE_SMTP_USER=votre-utilisateur-smtp +GOTRUE_SMTP_PASS=votre-mot-de-passe-smtp +GOTRUE_SMTP_ADMIN_EMAIL=noreply@votre-domaine.fr +GOTRUE_SMTP_SENDER_NAME="Ferrovia Panel" + +## Paramètres des liens d'authentification +GOTRUE_MAILER_AUTOCONFIRM=false +GOTRUE_MAILER_URLPATHS_RECOVERY=/reinitialiser-mot-de-passe +``` + +> **Note Fournisseurs SMTP recommandés :** Brevo (ex-Sendinblue), Mailjet, Resend, Amazon SES ou un serveur Postfix dédié. + +--- + +## 3. Installation du Template Email de Réinitialisation + +Le template email responsive Ferrovia est situé dans : +`supabase/templates/recovery.html` + +### Option A : Via Supabase Studio (Interface Web) +1. Rendez-vous sur votre interface Supabase Studio (`https://votre-supabase/project/default/auth/templates`). +2. Allez dans **Authentication** > **Email Templates**. +3. Sélectionnez l'onglet **Reset Password**. +4. Dans le champ **Subject**, indiquez : + `Réinitialisation de votre mot de passe - Ferrovia Panel` +5. Copiez-collez l'intégralité du code HTML du fichier `supabase/templates/recovery.html` dans le champ **Body**. +6. Cliquez sur **Save changes**. + +### Option B : Montage Docker (GoTrue) +Si votre déploiement Docker utilise des fichiers de templates montés : +1. Dans `docker-compose.yml`, montez le template dans le conteneur `auth` : + ```yaml + auth: + image: supabase/gotrue:... + volumes: + - ./templates/recovery.html:/etc/gotrue/templates/recovery.html:ro + environment: + GOTRUE_MAILER_TEMPLATES_RECOVERY: /etc/gotrue/templates/recovery.html + ``` +2. Redémarrez le conteneur auth : + ```bash + docker compose restart auth + ``` + +--- + +## 4. Parcours Utilisateur dans l'Application + +1. **Page de Connexion (`/`)** : + - L'utilisateur clique sur **"Mot de passe oublié ?"**. + - Si un email était déjà tapé, il est automatiquement pré-rempli sur la page de réinitialisation. + +2. **Page de Réinitialisation (`/reinitialiser-mot-de-passe`) - Étape 1** : + - L'utilisateur saisit son adresse email professionnelle. + - L'application appelle `supabase.auth.resetPasswordForEmail(email, { redirectTo })`. + - Supabase génère un token sécurisé et expédie l'email avec le template personnalisé. + +3. **Email Reçu** : + - L'utilisateur clique sur **"Définir mon nouveau mot de passe"**. + - Le lien redirige vers `https://votre-domaine.fr/reinitialiser-mot-de-passe#access_token=...&type=recovery`. + +4. **Page de Réinitialisation - Étape 2** : + - L'application détecte le mode récupération (`type=recovery`). + - L'utilisateur saisit son nouveau mot de passe (min. 8 caractères) et confirme. + - L'application appelle `supabase.auth.updateUser({ password })`. + - Message de confirmation et redirection automatique vers la connexion. diff --git a/supabase/config.toml b/supabase/config.toml index a9ff6d3..d3197f2 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -248,6 +248,10 @@ otp_expiry = 3600 # subject = "You have been invited" # content_path = "./supabase/templates/invite.html" +[auth.email.template.recovery] +subject = "Réinitialisation de votre mot de passe - Ferrovia Panel" +content_path = "./supabase/templates/recovery.html" + # Uncomment to customize notification email template # [auth.email.notification.password_changed] # enabled = true diff --git a/supabase/migrations/20261001230000_optimize_single_table_horaires.sql b/supabase/migrations/20261001230000_optimize_single_table_horaires.sql new file mode 100644 index 0000000..12a36aa --- /dev/null +++ b/supabase/migrations/20261001230000_optimize_single_table_horaires.sql @@ -0,0 +1,328 @@ +-- ============================================================================ +-- MIGRATION : OPTIMISATION DE LA TABLE UNIQUE "horaires" +-- Date : 2026-10-01 +-- Objectif : Consolider 100% des données relatives aux horaires (sillons, dessertes, +-- quais, arrêts intermédiaires, substitutions, calendrier, matériel, +-- temps réel) dans une seule et unique table "horaires" ultra-performante. +-- ============================================================================ + +-- 1. Ajout de toutes les colonnes nécessaires à la table horaires +DO $$ +BEGIN + -- Identifiants et informations train + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'nom_train') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "nom_train" varchar(255); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'code_mission') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "code_mission" varchar(50); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'sens') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "sens" varchar(20) DEFAULT 'aller'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'transporteur') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "transporteur" varchar(100) DEFAULT 'SNCF Voyageurs'; + END IF; + + -- Dessertes et attribution des quais + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'gares_desservies') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "gares_desservies" jsonb NOT NULL DEFAULT '[]'::jsonb; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'attributions_quais') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "attributions_quais" jsonb NOT NULL DEFAULT '{}'::jsonb; + END IF; + + -- Calendrier étendu et dates de validité + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'date_debut') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "date_debut" timestamptz; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'date_fin') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "date_fin" timestamptz; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'calendrier_circulation') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "calendrier_circulation" jsonb NOT NULL DEFAULT '[]'::jsonb; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'periode_validite') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "periode_validite" jsonb NOT NULL DEFAULT '{}'::jsonb; + END IF; + + -- Matériel et composition + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'composition_train') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "composition_train" jsonb NOT NULL DEFAULT '[]'::jsonb; + END IF; + + -- Région + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'region') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "region" varchar(255); + END IF; + + -- Service annuel et type de service + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'service_type') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "service_type" varchar(50) DEFAULT 'TER'; + END IF; + + -- Substitutions et autocars + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'est_substitution') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "est_substitution" boolean NOT NULL DEFAULT false; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'motif_substitution') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "motif_substitution" varchar(255); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'substitution_disponible') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "substitution_disponible" boolean NOT NULL DEFAULT false; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'horaire_id') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "horaire_id" uuid; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'substitutions_associees') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "substitutions_associees" jsonb NOT NULL DEFAULT '[]'::jsonb; + END IF; + + -- Sillons & infrastructure + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'sillon_id') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "sillon_id" text; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'tranche') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "tranche" varchar(50); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'gestionnaire_infrastructure') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "gestionnaire_infrastructure" varchar(100) DEFAULT 'SNCF Réseau'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'vitesse_max_kmh') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "vitesse_max_kmh" integer; + END IF; + + -- Exploitation temps réel et retards + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'statut') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "statut" varchar(50) NOT NULL DEFAULT 'a_l_heure'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'retard_minutes') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "retard_minutes" integer NOT NULL DEFAULT 0; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'retard_motif') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "retard_motif" text; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'informations_voyageurs') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "informations_voyageurs" text; + END IF; + + -- Métadonnées et notes + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'metadata') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "metadata" jsonb NOT NULL DEFAULT '{}'::jsonb; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'notes') THEN + ALTER TABLE "public"."horaires" ADD COLUMN "notes" text; + END IF; +END $$; + +-- 2. Migration des données relationnelles de horaires_gares_desservies vers horaires.gares_desservies (si la table existe) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'horaires_gares_desservies') THEN + -- Mettre à jour horaires.gares_desservies avec les lignes de la table relationnelle si gares_desservies est vide + UPDATE "public"."horaires" h + SET gares_desservies = sub.json_dessertes, + updated_at = NOW() + FROM ( + SELECT + horaire_id, + jsonb_agg( + jsonb_build_object( + 'gare_id', gare_id, + 'gare_nom', gare_nom, + 'heure_arrivee', heure_arrivee, + 'heure_depart', heure_depart, + 'ordre', ordre, + 'quai', quai + ) ORDER BY ordre ASC + ) AS json_dessertes + FROM "public"."horaires_gares_desservies" + GROUP BY horaire_id + ) sub + WHERE h.id = sub.horaire_id + AND (h.gares_desservies IS NULL OR h.gares_desservies = '[]'::jsonb); + END IF; +END $$; + +-- 3. Migration des substitutions de horaires_substitutions vers horaires (si la table existe) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'horaires_substitutions') THEN + INSERT INTO "public"."horaires" ( + "id", + "numero_train", + "type_train", + "type_train_id", + "gare_depart_id", + "gare_depart_nom", + "heure_depart", + "gare_arrivee_id", + "gare_arrivee_nom", + "heure_arrivee", + "date_debut", + "date_fin", + "circule_lundi", + "circule_mardi", + "circule_mercredi", + "circule_jeudi", + "circule_vendredi", + "circule_samedi", + "circule_dimanche", + "circule_jours_feries", + "jours_personnalises", + "jours_non_circulation", + "materiel_roulant_id", + "ligne_id", + "ligne_nom", + "service_annuel_id", + "service_annuel_nom", + "est_substitution", + "motif_substitution", + "gares_desservies", + "composition_train", + "substitution_disponible", + "actif", + "region_id", + "region", + "horaire_id", + "created_at", + "updated_at", + "deleted_at" + ) + SELECT + s."id", + COALESCE(s."numero_train", 'SUBST-' || substring(s."id"::text, 1, 6)), + s."type_train", + s."type_train_id", + s."gare_depart_id", + s."gare_depart_nom", + s."heure_depart", + s."gare_arrivee_id", + s."gare_arrivee_nom", + s."heure_arrivee", + s."date_debut", + s."date_fin", + COALESCE(s."circule_lundi", true), + COALESCE(s."circule_mardi", true), + COALESCE(s."circule_mercredi", true), + COALESCE(s."circule_jeudi", true), + COALESCE(s."circule_vendredi", true), + COALESCE(s."circule_samedi", true), + COALESCE(s."circule_dimanche", true), + COALESCE(s."circule_jours_feries", false), + COALESCE(s."jours_personnalises", '[]'::jsonb), + COALESCE(s."jours_non_circulation", '[]'::jsonb), + s."materiel_roulant_id", + s."ligne_id", + s."ligne_nom", + s."service_annuel_id", + s."service_annuel_nom", + true AS est_substitution, + s."motif_substitution", + COALESCE(s."gares_desservies", '[]'::jsonb), + COALESCE(s."composition_train", '[]'::jsonb), + false, + COALESCE(s."actif", true), + s."region_id", + s."region", + s."horaire_id", + COALESCE(s."created_at", NOW()), + COALESCE(s."updated_at", NOW()), + s."deleted_at" + FROM "public"."horaires_substitutions" s + ON CONFLICT ("id") DO UPDATE + SET est_substitution = true, + motif_substitution = EXCLUDED.motif_substitution, + date_debut = EXCLUDED.date_debut, + date_fin = EXCLUDED.date_fin, + updated_at = NOW(); + END IF; +END $$; + +-- 4. Initialisation du dictionnaire attributions_quais depuis gares_desservies +UPDATE "public"."horaires" +SET attributions_quais = ( + SELECT COALESCE( + jsonb_object_agg( + COALESCE(elem->>'gare_id', elem->>'gare_nom'), + jsonb_build_object( + 'quai', elem->>'quai', + 'quai_reel', elem->>'quai_reel', + 'ordre', elem->'ordre' + ) + ), + '{}'::jsonb + ) + FROM jsonb_array_elements(gares_desservies) elem + WHERE elem->>'quai' IS NOT NULL OR elem->>'quai_reel' IS NOT NULL +) +WHERE attributions_quais = '{}'::jsonb + AND gares_desservies IS NOT NULL + AND jsonb_array_length(gares_desservies) > 0; + +-- 5. Création des index haute performance (B-Tree et GIN) +CREATE INDEX IF NOT EXISTS "idx_horaires_numero_train" ON "public"."horaires" ("numero_train"); +CREATE INDEX IF NOT EXISTS "idx_horaires_ligne_id" ON "public"."horaires" ("ligne_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_region_id" ON "public"."horaires" ("region_id"); +CREATE INDEX IF NOT EXISTS "idx_horaires_departs" ON "public"."horaires" ("gare_depart_id", "heure_depart"); +CREATE INDEX IF NOT EXISTS "idx_horaires_arrivees" ON "public"."horaires" ("gare_arrivee_id", "heure_arrivee"); +CREATE INDEX IF NOT EXISTS "idx_horaires_actif" ON "public"."horaires" ("actif") WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS "idx_horaires_substitution" ON "public"."horaires" ("est_substitution"); +CREATE INDEX IF NOT EXISTS "idx_horaires_dates" ON "public"."horaires" ("date_debut", "date_fin"); +CREATE INDEX IF NOT EXISTS "idx_horaires_statut" ON "public"."horaires" ("statut"); + +-- Index GIN JSONB pour recherche et filtrage ultra-rapide sans jointure +CREATE INDEX IF NOT EXISTS "idx_horaires_gares_desservies_gin" + ON "public"."horaires" USING gin ("gares_desservies" jsonb_path_ops); + +CREATE INDEX IF NOT EXISTS "idx_horaires_attributions_quais_gin" + ON "public"."horaires" USING gin ("attributions_quais"); + +CREATE INDEX IF NOT EXISTS "idx_horaires_calendrier_gin" + ON "public"."horaires" USING gin ("calendrier_circulation" jsonb_path_ops); + +CREATE INDEX IF NOT EXISTS "idx_horaires_metadata_gin" + ON "public"."horaires" USING gin ("metadata"); + +-- 6. Trigger de mise à jour automatique de updated_at +CREATE OR REPLACE TRIGGER "tr_horaires_set_updated_at" +BEFORE UPDATE ON "public"."horaires" +FOR EACH ROW EXECUTE FUNCTION "public"."set_updated_at"(); + +-- 7. Politiques de sécurité RLS +ALTER TABLE "public"."horaires" ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Public read access" ON "public"."horaires"; +CREATE POLICY "Public read access" ON "public"."horaires" FOR SELECT USING (true); + +DROP POLICY IF EXISTS "Public insert access" ON "public"."horaires"; +CREATE POLICY "Public insert access" ON "public"."horaires" FOR INSERT WITH CHECK (true); + +DROP POLICY IF EXISTS "Public update access" ON "public"."horaires"; +CREATE POLICY "Public update access" ON "public"."horaires" FOR UPDATE USING (true); + +DROP POLICY IF EXISTS "Public delete access" ON "public"."horaires"; +CREATE POLICY "Public delete access" ON "public"."horaires" FOR DELETE USING (true); + +-- Commentaires d'optimisation +COMMENT ON TABLE "public"."horaires" IS 'Table UNIQUE et centrale pour stocker l''intégralité des données horaires, dessertes, quais, substitutions, calendrier et temps réel.'; +COMMENT ON COLUMN "public"."horaires"."gares_desservies" IS 'Liste JSONB complète et ordonnée de tous les arrêts, gares, horaires et quais.'; +COMMENT ON COLUMN "public"."horaires"."attributions_quais" IS 'Dictionnaire JSONB rapide O(1) { [gare_id]: { quai, quai_reel } } sans jointure.'; +COMMENT ON COLUMN "public"."horaires"."est_substitution" IS 'Vrai si ce sillon représente un autocar ou train de substitution.'; diff --git a/supabase/templates/recovery.html b/supabase/templates/recovery.html new file mode 100644 index 0000000..d665c84 --- /dev/null +++ b/supabase/templates/recovery.html @@ -0,0 +1,252 @@ + + + + + + Réinitialisation de votre mot de passe - Ferrovia Panel + + + + +
+
# N° TrainTypeLigne Départ Arrivée
{p.index + 1} {p.row?.numero_train || '-'}{p.row?.type_train || '-'}{p.row?.ligne_nom || '-'} {p.row?.gare_depart_nom || '-'} ({p.row?.heure_depart || '-'}) {p.row?.gare_arrivee_nom || '-'} ({p.row?.heure_arrivee || '-'})
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ +
+
+ +