diff --git a/migrations/create_fiches_horaires_and_storage.sql b/migrations/create_fiches_horaires_and_storage.sql new file mode 100644 index 0000000..8f9ec4f --- /dev/null +++ b/migrations/create_fiches_horaires_and_storage.sql @@ -0,0 +1,75 @@ +-- ============================================================================ +-- Migration : Table fiches_horaires et configuration Storage Fiches_Horaires +-- ============================================================================ + +-- 1. Création de la table fiches_horaires +CREATE TABLE IF NOT EXISTS public.fiches_horaires ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "titre" VARCHAR(255) NOT NULL, + "ligne_id" VARCHAR(100), + "ligne_nom" VARCHAR(255), + "code_ligne" VARCHAR(50), + "service_annuel_id" VARCHAR(100), + "service_annuel_nom" VARCHAR(255), + "code_service_annuel" VARCHAR(50), + "direction" VARCHAR(50) DEFAULT 'aller', -- mot-clé SQL réservé, doit être entouré de guillemets doubles + "statut" VARCHAR(50) DEFAULT 'Projet (Brouillon)', -- 'Projet (Brouillon)', 'En cours de production', 'Validée' + "pdf_url" TEXT, + "pdf_filename" VARCHAR(255), + "pdf_storage_path" TEXT, + "config" JSONB DEFAULT '{}'::jsonb, + "circulations_count" INT DEFAULT 0, + "gares_count" INT DEFAULT 0, + "region_id" VARCHAR(100), + "created_by" VARCHAR(255), + "created_at" TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + "updated_at" TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index pour accélérer les recherches +CREATE INDEX IF NOT EXISTS idx_fiches_horaires_ligne_id ON public.fiches_horaires("ligne_id"); +CREATE INDEX IF NOT EXISTS idx_fiches_horaires_service_id ON public.fiches_horaires("service_annuel_id"); +CREATE INDEX IF NOT EXISTS idx_fiches_horaires_statut ON public.fiches_horaires("statut"); +CREATE INDEX IF NOT EXISTS idx_fiches_horaires_region ON public.fiches_horaires("region_id"); + +-- RLS Policies pour la table fiches_horaires +ALTER TABLE public.fiches_horaires ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "fiches_horaires_lecture_publique" ON public.fiches_horaires; +DROP POLICY IF EXISTS "fiches_horaires_ecriture" ON public.fiches_horaires; + +CREATE POLICY "fiches_horaires_lecture_publique" + ON public.fiches_horaires FOR SELECT + USING (true); + +CREATE POLICY "fiches_horaires_ecriture" + ON public.fiches_horaires FOR ALL + USING (true) + WITH CHECK (true); + +-- 2. Création et configuration du bucket de stockage 'Fiches_Horaires' +INSERT INTO storage.buckets ("id", "name", "public") +VALUES ('Fiches_Horaires', 'Fiches_Horaires', true) +ON CONFLICT ("id") DO UPDATE SET "public" = true; + +-- Configuration des règles Storage pour 'Fiches_Horaires' +DROP POLICY IF EXISTS "Fiches_Horaires - lecture publique" ON storage.objects; +DROP POLICY IF EXISTS "Fiches_Horaires - upload" ON storage.objects; +DROP POLICY IF EXISTS "Fiches_Horaires - update" ON storage.objects; +DROP POLICY IF EXISTS "Fiches_Horaires - delete" ON storage.objects; + +CREATE POLICY "Fiches_Horaires - lecture publique" + ON storage.objects FOR SELECT + USING (bucket_id = 'Fiches_Horaires' OR bucket_id = 'fiches-horaires'); + +CREATE POLICY "Fiches_Horaires - upload" + ON storage.objects FOR INSERT + WITH CHECK (bucket_id = 'Fiches_Horaires' OR bucket_id = 'fiches-horaires'); + +CREATE POLICY "Fiches_Horaires - update" + ON storage.objects FOR UPDATE + USING (bucket_id = 'Fiches_Horaires' OR bucket_id = 'fiches-horaires'); + +CREATE POLICY "Fiches_Horaires - delete" + ON storage.objects FOR DELETE + USING (bucket_id = 'Fiches_Horaires' OR bucket_id = 'fiches-horaires'); diff --git a/package-lock.json b/package-lock.json index 462ec83..027c99a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@testing-library/user-event": "^13.5.0", "bcryptjs": "^3.0.3", "jspdf": "^2.5.2", + "jszip": "^3.10.2", "react": "^19.2.4", "react-dom": "^19.2.4", "react-scripts": "5.0.1", @@ -12923,6 +12924,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -14986,6 +14993,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -15072,6 +15127,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -16462,8 +16526,7 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)", - "peer": true + "license": "(MIT AND Zlib)" }, "node_modules/parallel-transform": { "version": "1.2.0", @@ -19915,8 +19978,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/setprototypeof": { "version": "1.2.0", diff --git a/package.json b/package.json index 8985c8a..c577c63 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@testing-library/user-event": "^13.5.0", "bcryptjs": "^3.0.3", "jspdf": "^2.5.2", + "jszip": "^3.10.2", "react": "^19.2.4", "react-dom": "^19.2.4", "react-scripts": "5.0.1", @@ -23,7 +24,8 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "updater": "node updater.js" }, "eslintConfig": { "extends": [ diff --git a/src/api/fichesHoraires.js b/src/api/fichesHoraires.js new file mode 100644 index 0000000..5f75e98 --- /dev/null +++ b/src/api/fichesHoraires.js @@ -0,0 +1,535 @@ +import supabase, { isSupabaseConfigured } from '../supabaseClient'; + +export const FH_PRIMARY_BUCKET = 'Fiches_Horaires'; +export const FH_FALLBACK_BUCKET = 'fiches-horaires'; +const STORAGE_KEY_LOCAL = 'fiches_horaires_local_records'; +const STORAGE_KEY_CONFIG = 'fiches_horaires_templates_config'; + +export const FICHE_STATUTS = [ + { value: 'Projet (Brouillon)', label: 'Projet (Brouillon)', color: '#64748b', bg: '#f1f5f9' }, + { value: 'En cours de production', label: 'En cours de production', color: '#d97706', bg: '#fef3c7' }, + { value: 'Validée', label: 'Validée', color: '#16a34a', bg: '#dcfce7' } +]; + +export const REGIONAL_PRESETS = { + 'bourgogne-franche-comte': { + regionName: 'Bourgogne-Franche-Comté', + brandName: 'Mobigo', + primaryColor: '#D91A5B', + secondaryColor: '#2B2B2A', + accentColor: '#B5154A', + alertBgColor: '#F59E0B', + networkLogoText: 'mobigo', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site TER Bourgogne-Franche-Comté', + website: 'www.ter.sncf.com/bourgogne-franche-comte', + phoneName: 'Mobigo', + phoneNumber: '03 80 11 29 29', + phoneHours: 'Du lundi au samedi de 7h à 20h', + appName: 'Site et appli Mobigo', + appWebsite: 'www.viamobigo.fr' + }, + assistanceText: 'Assist\'en Gare est un service gratuit destiné aux personnes en situation de handicap et à mobilité réduite pour tous les déplacements à l\'échelle régionale, nationale et internationale. Renseignements et réservations au 32 12 (service gratuit + prix d\'un appel) et sur le site de Gares & Connexions.', + bikeText: 'Train ou car autorisé au transport gratuit des vélos dans la limite des places disponibles, peut être soumis à réservation en fonction des périodes d\'affluence. Se renseigner sur le site internet TER Bourgogne-Franche-Comté avant votre voyage, rubrique « TRAIN Mobigo + Vélo ». Les tricycles, triporteurs, tandems et remorques ne sont pas autorisés à bord.', + tadText: 'Transport à la demande assuré par taxi pour les communes de Navilly, St-Bonnet, St-Germain, Devrouze, Simard, Ste-Croix, Dammartin et Frontenaud. Renseignements et réservations au plus tard la veille avant 17h00 auprès de Mobigo au 03 80 11 29 29.' + }, + 'auvergne-rhone-alpes': { + regionName: 'Auvergne-Rhône-Alpes', + brandName: 'TER AURA', + primaryColor: '#0070B8', + secondaryColor: '#1E293B', + accentColor: '#005A94', + alertBgColor: '#F59E0B', + networkLogoText: 'TER Auvergne-Rhône-Alpes', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site TER Auvergne-Rhône-Alpes', + website: 'www.ter.sncf.com/auvergne-rhone-alpes', + phoneName: 'Allo TER', + phoneNumber: '09 69 32 21 41', + phoneHours: 'Du lundi au vendredi de 7h à 19h', + appName: 'Appli SNCF Connect', + appWebsite: 'www.sncf-connect.com' + }, + assistanceText: 'Assist\'en Gare : Service d\'assistance gratuit réservable au 32 12 ou sur garesetconnexions.sncf.', + bikeText: 'Transport gratuit des vélos non démontés dans la limite des places disponibles.', + tadText: 'Services de correspondances et cars régionaux disponibles sur le site TER.' + }, + 'grand-est': { + regionName: 'Grand Est', + brandName: 'Fluo Grand Est', + primaryColor: '#F2C100', + secondaryColor: '#3A1E4B', + accentColor: '#3A1E4B', + alertBgColor: '#F59E0B', + networkLogoText: 'fluo Grand Est', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site Fluo Grand Est', + website: 'www.fluo.eu', + phoneName: 'Contact TER Fluo', + phoneNumber: '0 805 415 415', + phoneHours: 'Du lundi au vendredi de 6h30 à 19h30', + appName: 'Application Fluo', + appWebsite: 'www.fluo.eu/app' + }, + assistanceText: 'Assist\'en Gare : Service d\'assistance PMR au 32 12.', + bikeText: 'Emplacements vélos gratuits à bord des trains TER Fluo selon disponibilité.', + tadText: 'Réservations TAD via la centrale Fluo Grand Est.' + }, + 'bretagne': { + regionName: 'Bretagne', + brandName: 'BreizhGo', + primaryColor: '#009AA6', + secondaryColor: '#1C1F2B', + accentColor: '#007A84', + alertBgColor: '#F59E0B', + networkLogoText: 'BreizhGo', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site TER BreizhGo', + website: 'www.ter.sncf.com/bretagne', + phoneName: 'Centrale BreizhGo', + phoneNumber: '02 99 300 300', + phoneHours: 'Du lundi au samedi de 8h à 20h', + appName: 'Appli BreizhGo', + appWebsite: 'www.breizhgo.bzh' + }, + assistanceText: 'Assist\'en Gare : Renseignements et réservations au 32 12.', + bikeText: 'Espaces vélos disponibles à bord. Réservation gratuite obligatoire en période estivale.', + tadText: 'Cars et TAD BreizhGo accessibles sur breizhgo.bzh.' + }, + 'normandie': { + regionName: 'Normandie', + brandName: 'Nomad Train', + primaryColor: '#E2001A', + secondaryColor: '#1F2937', + accentColor: '#B80015', + alertBgColor: '#F59E0B', + networkLogoText: 'NOMAD Train', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site NOMAD Train Normandie', + website: 'www.ter.sncf.com/normandie', + phoneName: 'Contact NOMAD', + phoneNumber: '0 800 801 801', + phoneHours: 'Du lundi au samedi de 6h à 21h', + appName: 'Appli Nomad Car & Train', + appWebsite: 'www.nomad.normandie.fr' + }, + assistanceText: 'Assist\'en Gare au 32 12 pour les personnes à mobilité réduite.', + bikeText: 'Emplacements vélos gratuits dans tous les trains régionaux Nomad.', + tadText: 'Renseignements Nomad Car sur le site régional.' + }, + 'centre-val-de-loire': { + regionName: 'Centre-Val de Loire', + brandName: 'Rémi Train', + primaryColor: '#E85D04', + secondaryColor: '#002B49', + accentColor: '#D00000', + alertBgColor: '#F59E0B', + networkLogoText: 'Rémi Centre-Val de Loire', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site Rémi Centre-Val de Loire', + website: 'www.remi-centrevaldeloire.fr', + phoneName: 'Centrale Rémi', + phoneNumber: '0 806 70 33 33', + phoneHours: 'Du lundi au samedi de 6h à 20h', + appName: 'Appli Rémi', + appWebsite: 'www.remi-centrevaldeloire.fr' + }, + assistanceText: 'Assist\'en Gare au 32 12 pour assistance en gare et montée à bord.', + bikeText: 'Accès vélo gratuit selon places disponibles.', + tadText: 'Réseau de cars et TAD Rémi disponible au 0 806 70 33 33.' + }, + 'occitanie': { + regionName: 'Occitanie', + brandName: 'liO Train', + primaryColor: '#C8102E', + secondaryColor: '#1E293B', + accentColor: '#9B0018', + alertBgColor: '#F59E0B', + networkLogoText: 'liO Occitanie', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site liO Train Occitanie', + website: 'www.ter.sncf.com/occitanie', + phoneName: 'Centre Relation Clients liO', + phoneNumber: '0 800 31 31 31', + phoneHours: 'Du lundi au vendredi de 7h à 20h', + appName: 'Appli liO Occitanie', + appWebsite: 'www.lio-occitanie.fr' + }, + assistanceText: 'Assist\'en Gare au 32 12.', + bikeText: 'Espaces vélos accessibles gratuitement dans la limite des places.', + tadText: 'Consultez le site liO pour les navettes et cars départementaux.' + }, + 'nouvelle-aquitaine': { + regionName: 'Nouvelle-Aquitaine', + brandName: 'TER Nouvelle-Aquitaine', + primaryColor: '#A81327', + secondaryColor: '#1E293B', + accentColor: '#800D1C', + alertBgColor: '#F59E0B', + networkLogoText: 'TER Nouvelle-Aquitaine', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site TER Nouvelle-Aquitaine', + website: 'www.ter.sncf.com/nouvelle-aquitaine', + phoneName: 'Contact TER NA', + phoneNumber: '0 800 872 872', + phoneHours: 'Du lundi au vendredi de 6h30 à 19h30', + appName: 'SNCF Connect', + appWebsite: 'www.sncf-connect.com' + }, + assistanceText: 'Assist\'en Gare au 32 12.', + bikeText: 'Emplacements vélos gratuits à bord dans la limite des places.', + tadText: 'Réseau de cars régionaux Nouvelle-Aquitaine.' + }, + 'hauts-de-france': { + regionName: 'Hauts-de-France', + brandName: 'TER Hauts-de-France', + primaryColor: '#003399', + secondaryColor: '#FFCC00', + accentColor: '#002266', + alertBgColor: '#F59E0B', + networkLogoText: 'TER Hauts-de-France', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site TER Hauts-de-France', + website: 'www.ter.sncf.com/hauts-de-france', + phoneName: 'Contact TER HDF', + phoneNumber: '0 805 50 60 70', + phoneHours: 'Du lundi au vendredi de 6h à 20h', + appName: 'SNCF Connect & Pass Pass', + appWebsite: 'www.passpass.fr' + }, + assistanceText: 'Assist\'en Gare au 32 12.', + bikeText: 'Espace vélos libre d\'accès selon places disponibles.', + tadText: 'Lignes de cars régionales Hauts-de-France.' + }, + 'pays-de-la-loire': { + regionName: 'Pays de la Loire', + brandName: 'Aléop en TER', + primaryColor: '#008559', + secondaryColor: '#1C2733', + accentColor: '#006644', + alertBgColor: '#F59E0B', + networkLogoText: 'Aléop Pays de la Loire', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site Aléop en TER', + website: 'www.ter.sncf.com/pays-de-la-loire', + phoneName: 'Contact Aléop', + phoneNumber: '0 800 210 800', + phoneHours: 'Du lundi au vendredi de 7h à 20h', + appName: 'Appli Aléop', + appWebsite: 'www.aleop.paysdelaloire.fr' + }, + assistanceText: 'Assist\'en Gare au 32 12.', + bikeText: 'Emplacements vélos disponibles gratuitement.', + tadText: 'Service de transport à la demande Aléop.' + }, + 'provence-alpes-cote-d-azur': { + regionName: 'Provence-Alpes-Côte d\'Azur', + brandName: 'ZOU !', + primaryColor: '#00A3E0', + secondaryColor: '#F9B200', + accentColor: '#007FA6', + alertBgColor: '#F59E0B', + networkLogoText: 'ZOU ! Sud', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site ZOU ! Sud Provence-Alpes-Côte d\'Azur', + website: 'www.ter.sncf.com/sud-provence-alpes-cote-d-azur', + phoneName: 'Centre d\'appels ZOU !', + phoneNumber: '0 800 11 40 23', + phoneHours: 'Tous les jours de 7h à 20h', + appName: 'Appli ZOU !', + appWebsite: 'www.zou.maregionsud.fr' + }, + assistanceText: 'Assist\'en Gare au 32 12.', + bikeText: 'Transport gratuit des vélos selon disponibilité des rames.', + tadText: 'Réseau ZOU ! Cars et navettes régionales.' + }, + 'default': { + regionName: 'Région', + brandName: 'TER', + primaryColor: '#D91A5B', + secondaryColor: '#1E293B', + accentColor: '#B5154A', + alertBgColor: '#F59E0B', + networkLogoText: 'TER Mobilité', + operatorName: 'SNCF VOYAGEURS', + contact: { + title: 'Site TER Régional', + website: 'www.ter.sncf.com', + phoneName: 'Service Client', + phoneNumber: '36 35', + phoneHours: '7j/7 de 7h à 20h', + appName: 'SNCF Connect', + appWebsite: 'www.sncf-connect.com' + }, + assistanceText: 'Assist\'en Gare au 32 12.', + bikeText: 'Transport gratuit des vélos dans la limite des places disponibles.', + tadText: 'Horaires et correspondances disponibles sur le site internet TER.' + } +}; + +/** + * Harmonisation du nom de fichier PDF : + * NOM_DE_LA_LIGNE_(CODE LIGNE)_(CODE SERVICE ANNUEL).pdf + */ +export function generateHarmonizedFileName(ligneNom, codeLigne, codeServiceAnnuel) { + const sanitize = (str) => { + if (!str) return ''; + return String(str) + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9_-]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') + .toUpperCase(); + }; + + const cleanLigneNom = sanitize(ligneNom) || 'LIGNE'; + const cleanCodeLigne = sanitize(codeLigne) || 'LIGNE'; + const cleanCodeSA = sanitize(codeServiceAnnuel) || 'SA'; + + return `${cleanLigneNom}_(${cleanCodeLigne})_(${cleanCodeSA}).pdf`; +} + +/** + * Récupère la configuration des templates (par région + personnalisations) + */ +export function getFichesHorairesTemplatesConfig() { + try { + const stored = localStorage.getItem(STORAGE_KEY_CONFIG); + if (stored) { + const parsed = JSON.parse(stored); + return { ...REGIONAL_PRESETS, ...parsed }; + } + } catch (e) { + console.warn('Erreur lecture template config localStorage:', e); + } + return { ...REGIONAL_PRESETS }; +} + +/** + * Sauvegarde la configuration des templates + */ +export function saveFichesHorairesTemplatesConfig(config) { + try { + localStorage.setItem(STORAGE_KEY_CONFIG, JSON.stringify(config)); + return { ok: true }; + } catch (e) { + console.error('Erreur sauvegarde template config:', e); + return { ok: false, error: e }; + } +} + +/** + * Récupère la configuration adaptée pour une région spécifique + */ +export function getTemplateConfigForRegion(regionId) { + const all = getFichesHorairesTemplatesConfig(); + const cleanId = String(regionId || '').toLowerCase().trim(); + return all[cleanId] || all['bourgogne-franche-comte'] || REGIONAL_PRESETS['bourgogne-franche-comte']; +} + +// --- Helpers localStorage pour résilience --- +function getLocalRecords() { + try { + const raw = localStorage.getItem(STORAGE_KEY_LOCAL); + return raw ? JSON.parse(raw) : []; + } catch (e) { + return []; + } +} + +function setLocalRecords(records) { + try { + localStorage.setItem(STORAGE_KEY_LOCAL, JSON.stringify(records)); + } catch (e) { + console.warn('LocalStorage save error for fiches_horaires:', e); + } +} + +/** + * Récupère toutes les fiches horaires enregistrées + */ +export async function getAllFichesHoraires() { + if (isSupabaseConfigured) { + try { + const { data, error } = await supabase + .from('fiches_horaires') + .select('*') + .order('updated_at', { ascending: false }); + + if (!error && Array.isArray(data)) { + // Synchroniser avec local + setLocalRecords(data); + return { data, error: null }; + } + } catch (err) { + console.warn('Table fiches_horaires inaccessible, bascule sur localStorage:', err); + } + } + // Fallback local + return { data: getLocalRecords(), error: null }; +} + +/** + * Récupère une fiche horaire par ID + */ +export async function getFicheHoraireById(id) { + if (!id) return { data: null, error: new Error('ID manquant') }; + const { data: all } = await getAllFichesHoraires(); + const found = (all || []).find(f => String(f.id) === String(id)); + return { data: found || null, error: found ? null : new Error('Fiche horaire introuvable') }; +} + +/** + * Upload du PDF dans Supabase Storage (Bucket Fiches_Horaires avec fallback fiches-horaires) + */ +export async function uploadFicheHorairePDF(pdfBlob, filename) { + if (!pdfBlob) return { url: null, path: null, error: new Error('Fichier PDF manquant') }; + const safeFilename = filename || `fiche_${Date.now()}.pdf`; + const storagePath = `exports/${Date.now()}_${safeFilename}`; + + if (isSupabaseConfigured) { + const candidateBuckets = [FH_PRIMARY_BUCKET, FH_FALLBACK_BUCKET]; + for (const bucket of candidateBuckets) { + try { + const { error: uploadError } = await supabase.storage + .from(bucket) + .upload(storagePath, pdfBlob, { + contentType: 'application/pdf', + upsert: true + }); + + if (!uploadError) { + const { data: pubData } = supabase.storage.from(bucket).getPublicUrl(storagePath); + const publicUrl = pubData?.publicUrl || null; + return { url: publicUrl, path: `${bucket}/${storagePath}`, error: null }; + } + } catch (err) { + console.warn(`Tentative d'upload sur bucket ${bucket} échouée:`, err); + } + } + } + + // Si Supabase Storage n'est pas configuré ou si les buckets sont inaccessibles, + // créer une Object URL pour permettre l'aperçu et le téléchargement direct + const localUrl = URL.createObjectURL(pdfBlob); + return { url: localUrl, path: `local/${safeFilename}`, error: null, isLocalBlob: true }; +} + +/** + * Sauvegarde ou met à jour une fiche horaire + */ +export async function saveFicheHoraire(record) { + const id = record.id || (window.crypto?.randomUUID ? window.crypto.randomUUID() : `fh_${Date.now()}`); + const now = new Date().toISOString(); + + const entry = { + ...record, + id, + updated_at: now, + created_at: record.created_at || now, + statut: record.statut || 'Projet (Brouillon)' + }; + + if (isSupabaseConfigured) { + try { + const { data, error } = await supabase + .from('fiches_horaires') + .upsert(entry) + .select() + .single(); + + if (!error && data) { + // Update local cache + const local = getLocalRecords(); + const filtered = local.filter(f => String(f.id) !== String(id)); + setLocalRecords([data, ...filtered]); + return { data, error: null }; + } + } catch (err) { + console.warn('Upsert fiches_horaires Supabase échoué, sauvegarde locale:', err); + } + } + + // Local fallback + const local = getLocalRecords(); + const filtered = local.filter(f => String(f.id) !== String(id)); + const updated = [entry, ...filtered]; + setLocalRecords(updated); + return { data: entry, error: null }; +} + +/** + * Mise à jour rapide du statut d'une fiche horaire + */ +export async function updateFicheHoraireStatus(id, newStatut) { + if (!id || !newStatut) return { error: new Error('Paramètres manquants') }; + + const validStatut = FICHE_STATUTS.some(s => s.value === newStatut) ? newStatut : 'Projet (Brouillon)'; + + if (isSupabaseConfigured) { + try { + const { data, error } = await supabase + .from('fiches_horaires') + .update({ statut: validStatut, updated_at: new Date().toISOString() }) + .eq('id', id) + .select() + .single(); + + if (!error && data) { + const local = getLocalRecords().map(f => String(f.id) === String(id) ? { ...f, statut: validStatut } : f); + setLocalRecords(local); + return { data, error: null }; + } + } catch (e) { + console.warn('Update statut Supabase error:', e); + } + } + + // Local fallback + const local = getLocalRecords().map(f => String(f.id) === String(id) ? { ...f, statut: validStatut } : f); + setLocalRecords(local); + const updated = local.find(f => String(f.id) === String(id)); + return { data: updated || null, error: null }; +} + +/** + * Suppression d'une fiche horaire et de son PDF associé + */ +export async function deleteFicheHoraire(id, pdfStoragePath) { + if (!id) return { error: new Error('ID manquant') }; + + if (isSupabaseConfigured) { + try { + // Delete record + await supabase.from('fiches_horaires').delete().eq('id', id); + + // Delete storage file if path provided + if (pdfStoragePath && typeof pdfStoragePath === 'string') { + const parts = pdfStoragePath.split('/'); + const bucket = parts[0] || FH_PRIMARY_BUCKET; + const path = parts.slice(1).join('/'); + if (path) { + await supabase.storage.from(bucket).remove([path]); + } + } + } catch (e) { + console.warn('Delete Supabase error:', e); + } + } + + const local = getLocalRecords().filter(f => String(f.id) !== String(id)); + setLocalRecords(local); + return { error: null }; +} diff --git a/src/api/gtfs.js b/src/api/gtfs.js new file mode 100644 index 0000000..57e77c6 --- /dev/null +++ b/src/api/gtfs.js @@ -0,0 +1,501 @@ +import JSZip from 'jszip'; +import supabase from '../supabaseClient'; + +/** + * Fast & safe CSV parser for GTFS text files + * Handles quotes, commas inside fields, Windows/Unix newlines, and UTF-8 BOM. + */ +export function parseCSV(text) { + if (!text || typeof text !== 'string') return []; + + // Remove UTF-8 BOM if present + const cleanText = text.replace(/^\uFEFF/, '').trim(); + if (!cleanText) return []; + + const lines = cleanText.split(/\r\n|\n|\r/); + if (lines.length === 0) return []; + + const headers = parseCSVLine(lines[0]).map(h => h.trim()); + const result = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + const values = parseCSVLine(line); + const obj = {}; + for (let j = 0; j < headers.length; j++) { + obj[headers[j]] = values[j] !== undefined ? values[j].trim() : ''; + } + result.push(obj); + } + + return result; +} + +function parseCSVLine(line) { + const result = []; + let cur = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + cur += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + result.push(cur); + cur = ''; + } else { + cur += char; + } + } + result.push(cur); + return result; +} + +/** + * Format GTFS date YYYYMMDD to YYYY-MM-DD + */ +export function formatGTFSDate(gtfsDateStr) { + if (!gtfsDateStr || typeof gtfsDateStr !== 'string') return ''; + const clean = gtfsDateStr.replace(/\D/g, ''); + if (clean.length === 8) { + return `${clean.slice(0, 4)}-${clean.slice(4, 6)}-${clean.slice(6, 8)}`; + } + return gtfsDateStr; +} + +/** + * Format GTFS time (HH:MM:SS or H:MM:SS, can exceed 24h like 25:10:00) to HH:MM + */ +export function formatGTFSTime(gtfsTimeStr) { + if (!gtfsTimeStr || typeof gtfsTimeStr !== 'string') return '00:00'; + const parts = gtfsTimeStr.split(':'); + if (parts.length >= 2) { + let h = parseInt(parts[0], 10) || 0; + if (h >= 24) h = h % 24; // Standard 24h format for display/storage + const m = parts[1].padStart(2, '0'); + return `${String(h).padStart(2, '0')}:${m}`; + } + return gtfsTimeStr; +} + +/** + * Parse an uploaded or downloaded GTFS ZIP archive + * Returns raw entities and indexed maps for rapid traversal. + */ +export async function parseGTFSZip(fileOrBuffer) { + const zip = new JSZip(); + const contents = await zip.loadAsync(fileOrBuffer); + + const getFileContent = async (fileName) => { + // Try direct or case-insensitive search + let fileEntry = contents.file(fileName); + if (!fileEntry) { + const lower = fileName.toLowerCase(); + const foundKey = Object.keys(contents.files).find(k => k.toLowerCase().endsWith(lower)); + if (foundKey) fileEntry = contents.file(foundKey); + } + if (!fileEntry) return null; + return await fileEntry.async('string'); + }; + + const [ + agencyText, + routesText, + tripsText, + stopTimesText, + stopsText, + calendarText, + calendarDatesText + ] = await Promise.all([ + getFileContent('agency.txt'), + getFileContent('routes.txt'), + getFileContent('trips.txt'), + getFileContent('stop_times.txt'), + getFileContent('stops.txt'), + getFileContent('calendar.txt'), + getFileContent('calendar_dates.txt') + ]); + + const agency = parseCSV(agencyText); + const routes = parseCSV(routesText); + const trips = parseCSV(tripsText); + const stop_times = parseCSV(stopTimesText); + const stops = parseCSV(stopsText); + const calendar = parseCSV(calendarText); + const calendar_dates = parseCSV(calendarDatesText); + + // Build indexing maps + const routesById = {}; + routes.forEach(r => { routesById[r.route_id] = r; }); + + const stopsById = {}; + stops.forEach(s => { stopsById[s.stop_id] = s; }); + + const calendarByService = {}; + calendar.forEach(c => { + calendarByService[c.service_id] = { + ...c, + start_date_formatted: formatGTFSDate(c.start_date), + end_date_formatted: formatGTFSDate(c.end_date) + }; + }); + + const calendarDatesByService = {}; + calendar_dates.forEach(cd => { + if (!calendarDatesByService[cd.service_id]) { + calendarDatesByService[cd.service_id] = []; + } + calendarDatesByService[cd.service_id].push({ + ...cd, + date_formatted: formatGTFSDate(cd.date) + }); + }); + + // Group stop_times by trip_id and sort by stop_sequence + const stopTimesByTrip = {}; + stop_times.forEach(st => { + if (!stopTimesByTrip[st.trip_id]) { + stopTimesByTrip[st.trip_id] = []; + } + stopTimesByTrip[st.trip_id].push(st); + }); + + Object.keys(stopTimesByTrip).forEach(tripId => { + stopTimesByTrip[tripId].sort((a, b) => (parseInt(a.stop_sequence, 10) || 0) - (parseInt(b.stop_sequence, 10) || 0)); + }); + + // Group trips by route_id + const tripsByRoute = {}; + trips.forEach(t => { + if (!tripsByRoute[t.route_id]) { + tripsByRoute[t.route_id] = []; + } + tripsByRoute[t.route_id].push(t); + }); + + return { + agency, + routes, + trips, + stop_times, + stops, + calendar, + calendar_dates, + routesById, + stopsById, + calendarByService, + calendarDatesByService, + stopTimesByTrip, + tripsByRoute + }; +} + +/** + * Fetch and parse a remote GTFS ZIP file from URL + */ +export async function fetchAndParseGTFSUrl(url) { + if (!url) throw new Error('URL GTFS invalide'); + + // Fetch binary zip archive + const res = await fetch(url, { method: 'GET' }); + if (!res.ok) { + throw new Error(`Échec du téléchargement GTFS (HTTP ${res.status}): ${res.statusText}`); + } + + const arrayBuffer = await res.arrayBuffer(); + return await parseGTFSZip(arrayBuffer); +} + +/** + * Calculate dates of circulation for a given GTFS service_id + */ +export function getCirculationDatesForService(serviceId, calendarByService, calendarDatesByService) { + const datesSet = new Set(); + const cal = calendarByService[serviceId]; + const exceptions = calendarDatesByService[serviceId] || []; + + if (cal && cal.start_date_formatted && cal.end_date_formatted) { + const start = new Date(cal.start_date_formatted); + const end = new Date(cal.end_date_formatted); + const cur = new Date(start); + + const dows = { + 1: cal.monday === '1', + 2: cal.tuesday === '1', + 3: cal.wednesday === '1', + 4: cal.thursday === '1', + 5: cal.friday === '1', + 6: cal.saturday === '1', + 0: cal.sunday === '1' + }; + + while (cur <= end) { + const dow = cur.getUTCDay(); + if (dows[dow]) { + datesSet.add(cur.toISOString().slice(0, 10)); + } + cur.setUTCDate(cur.getUTCDate() + 1); + } + } + + // Apply exceptions: 1 = added date, 2 = removed date + exceptions.forEach(exc => { + const d = exc.date_formatted; + if (!d) return; + if (exc.exception_type === '1') { + datesSet.add(d); + } else if (exc.exception_type === '2') { + datesSet.delete(d); + } + }); + + return Array.from(datesSet).sort(); +} + +/** + * Identify matching Service Annuel for a GTFS trip based on its circulation dates + */ +export function matchServiceAnnuelForGTFSTrip(trip, calendarByService = {}, calendarDatesByService = {}, servicesAnnuels = []) { + if (!trip || !servicesAnnuels || servicesAnnuels.length === 0) return null; + + const cal = calendarByService[trip.service_id]; + let firstDate = null; + let lastDate = null; + + if (cal && (cal.start_date_formatted || cal.start_date)) { + firstDate = cal.start_date_formatted || formatGTFSDate(cal.start_date); + lastDate = cal.end_date_formatted || formatGTFSDate(cal.end_date); + } + + const excs = calendarDatesByService[trip.service_id] || []; + if (excs.length > 0) { + const sortedExcs = excs.map(e => e.date_formatted || formatGTFSDate(e.date)).filter(Boolean).sort(); + if (sortedExcs.length > 0) { + const minExc = sortedExcs[0]; + const maxExc = sortedExcs[sortedExcs.length - 1]; + if (!firstDate || minExc < firstDate) firstDate = minExc; + if (!lastDate || maxExc > lastDate) lastDate = maxExc; + } + } + + if (!firstDate || !lastDate) return null; + + // 1. Direct enclosing match: SA covers the entire trip circulation range + const enclosingMatch = servicesAnnuels.find(sa => { + const s = sa.date_debut || '1970-01-01'; + const e = sa.date_fin || '2099-12-31'; + return firstDate >= s && lastDate <= e; + }); + if (enclosingMatch) return enclosingMatch; + + // 2. Overlapping match: SA intersects with the trip circulation range + const overlappingMatch = servicesAnnuels.find(sa => { + const s = sa.date_debut || '1970-01-01'; + const e = sa.date_fin || '2099-12-31'; + return !(lastDate < s || firstDate > e); + }); + if (overlappingMatch) return overlappingMatch; + + return null; +} + +/** + * Convert selected GTFS trips into the standard parsedRows array used by ImportsPage + */ +export function convertGTFSTripsToHoraireRows(optionsOrTrips, gtfsDataArg, allGaresArg, lignesArg, servicesAnnuelsArg, typesTrainsListArg, materielsArg) { + let selectedTrips, stopTimesByTrip, stopsById, routesById, calendarByService, calendarDatesByService, allGares, lignes, servicesAnnuels, targetLigneId, targetServiceAnnuelId; + + if (Array.isArray(optionsOrTrips)) { + selectedTrips = optionsOrTrips; + const data = gtfsDataArg || {}; + stopTimesByTrip = data.stopTimesByTrip || {}; + stopsById = data.stopsById || {}; + routesById = data.routesById || {}; + calendarByService = data.calendarByService || {}; + calendarDatesByService = data.calendarDatesByService || {}; + allGares = allGaresArg || []; + lignes = lignesArg || []; + servicesAnnuels = servicesAnnuelsArg || []; + } else { + const opts = optionsOrTrips || {}; + selectedTrips = opts.selectedTrips || []; + stopTimesByTrip = opts.stopTimesByTrip || {}; + stopsById = opts.stopsById || {}; + routesById = opts.routesById || {}; + calendarByService = opts.calendarByService || {}; + calendarDatesByService = opts.calendarDatesByService || {}; + allGares = opts.allGares || []; + lignes = opts.lignes || []; + servicesAnnuels = opts.servicesAnnuels || []; + targetLigneId = opts.targetLigneId || null; + targetServiceAnnuelId = opts.targetServiceAnnuelId || null; + } + + const findGareMatch = (stop) => { + if (!stop) return null; + const sName = (stop.stop_name || '').trim().toLowerCase(); + const sCode = (stop.stop_code || '').trim().toLowerCase(); + + // 1. Direct code match + if (sCode) { + const byCode = allGares.find(g => (g.code || '').trim().toLowerCase() === sCode); + if (byCode) return byCode; + } + + // 2. Direct name match + const byName = allGares.find(g => (g.nom || '').trim().toLowerCase() === sName); + if (byName) return byName; + + // 3. Normalized name match (removing accents & punctuation) + const normalize = (s) => (s || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/gi, '').toLowerCase(); + const normSName = normalize(stop.stop_name); + if (normSName) { + const byNorm = allGares.find(g => normalize(g.nom) === normSName); + if (byNorm) return byNorm; + } + + return null; + }; + + const targetLigne = lignes.find(l => String(l.id) === String(targetLigneId)); + const targetService = servicesAnnuels.find(s => String(s.id) === String(targetServiceAnnuelId)); + + return selectedTrips.map((trip, idx) => { + const route = routesById[trip.route_id] || {}; + const stList = stopTimesByTrip[trip.trip_id] || []; + + const firstSt = stList[0] || {}; + const lastSt = stList.length > 1 ? stList[stList.length - 1] : firstSt; + + const firstStop = stopsById[firstSt.stop_id] || {}; + const lastStop = stopsById[lastSt.stop_id] || {}; + + const gareDepMatch = findGareMatch(firstStop); + const gareArrMatch = findGareMatch(lastStop); + + const depNom = gareDepMatch?.nom || firstStop.stop_name || 'Gare Inconnue'; + const arrNom = gareArrMatch?.nom || lastStop.stop_name || 'Gare Inconnue'; + + const heureDep = formatGTFSTime(firstSt.departure_time || firstSt.arrival_time); + const heureArr = formatGTFSTime(lastSt.arrival_time || lastSt.departure_time); + + // Intermediate stops + const intermediateStops = stList.slice(1, -1).map((st, i) => { + const stop = stopsById[st.stop_id] || {}; + const gMatch = findGareMatch(stop); + return { + gare_id: gMatch?.id || null, + gare_nom: gMatch?.nom || stop.stop_name || `Arrêt ${st.stop_id}`, + heure_arrivee: formatGTFSTime(st.arrival_time || st.departure_time), + heure_depart: formatGTFSTime(st.departure_time || st.arrival_time), + ordre: i + 1 + }; + }); + + // Calendar & Days + const cal = calendarByService[trip.service_id] || {}; + const dates = getCirculationDatesForService(trip.service_id, calendarByService, calendarDatesByService); + + // Detect substitution (Bus / Coach / Car / replacement route_type = 3 or 700-716) + const isBusRoute = String(route.route_type) === '3' || (parseInt(route.route_type, 10) >= 700 && parseInt(route.route_type, 10) <= 716); + const isSubstitution = isBusRoute || (trip.trip_short_name || '').startsWith('BUS') || (trip.trip_headsign || '').toLowerCase().includes('substitution'); + + // Resolve SA + const resolvedSA = targetService || matchServiceAnnuelForGTFSTrip(trip, calendarByService, calendarDatesByService, servicesAnnuels); + + const numeroTrain = trip.trip_short_name || trip.trip_headsign || trip.trip_id || `GTFS-${idx + 1}`; + + return { + id: '', + numero_train: numeroTrain, + type_train: isSubstitution ? 'Autocar' : (route.route_short_name || 'TER'), + type_train_id: null, + ligne_id: targetLigne?.id || null, + ligne_nom: targetLigne?.nom || route.route_long_name || route.route_short_name || 'Ligne GTFS', + gare_depart_id: gareDepMatch?.id || null, + gare_depart_nom: depNom, + heure_depart: heureDep, + gare_arrivee_id: gareArrMatch?.id || null, + gare_arrivee_nom: arrNom, + heure_arrivee: heureArr, + circule_lundi: cal.monday === '1', + circule_mardi: cal.tuesday === '1', + circule_mercredi: cal.wednesday === '1', + circule_jeudi: cal.thursday === '1', + circule_vendredi: cal.friday === '1', + circule_samedi: cal.saturday === '1', + circule_dimanche: cal.sunday === '1', + circule_jours_feries: false, + circule_dimanches_feries: false, + jours_circulation: dates.join(';'), + nb_jours_circulation: dates.length, + jours_personnalises: dates.map(d => ({ date: d, circule: true })), + gares_desservies: JSON.stringify(intermediateStops), + service_annuel_id: resolvedSA?.id || null, + service_annuel_nom: resolvedSA?.nom || null, + est_substitution: isSubstitution, + motif_substitution: isSubstitution ? (targetLigne?.nom ? `Substitution ligne ${targetLigne.nom}` : 'Substitution travaux GTFS') : null, + _gtfsTripId: trip.trip_id, + _gtfsRouteId: trip.route_id, + _gtfsServiceId: trip.service_id, + _modified: false, + _duplicate: false, + _existingVariant: false, + _duplicateReason: '' + }; + }); +} + +/** + * Storage helpers for GTFS feed URL + */ +const STORAGE_KEY_GTFS_URL = 'ferrovia_gtfs_auto_url'; + +export async function getSavedGTFSConfig() { + try { + // 1. Try Supabase parameters table + const { data, error } = await supabase + .from('parametres') + .select('valeur') + .eq('cle', 'gtfs_sync_config') + .maybeSingle(); + + if (!error && data?.valeur) { + return data.valeur; + } + } catch (e) { + console.warn('Erreur lecture config GTFS Supabase:', e); + } + + // 2. Fallback to localStorage + const local = localStorage.getItem(STORAGE_KEY_GTFS_URL); + return local ? { url: local, lastSync: null } : { url: '', lastSync: null }; +} + +export async function saveSavedGTFSConfig(config) { + if (!config || !config.url) return; + + localStorage.setItem(STORAGE_KEY_GTFS_URL, config.url); + + try { + await supabase + .from('parametres') + .upsert({ + cle: 'gtfs_sync_config', + valeur: { + url: config.url, + lastSync: new Date().toISOString(), + name: config.name || 'Flux GTFS Principal' + }, + updated_at: new Date().toISOString() + }, { onConflict: 'cle' }); + } catch (e) { + console.warn('Erreur sauvegarde config GTFS Supabase:', e); + } +} diff --git a/src/api/infosSillons.js b/src/api/infosSillons.js new file mode 100644 index 0000000..952da87 --- /dev/null +++ b/src/api/infosSillons.js @@ -0,0 +1,128 @@ +import supabase from '../supabaseClient'; + +function applyRegionFilter(query, regionId) { + if (!regionId) return query; + return query.eq('region_id', regionId); +} + +export async function getAllInfosSillons(regionId = null) { + try { + const query = applyRegionFilter( + supabase.from('infos_sillons').select('*').order('created_at', { ascending: false }), + regionId + ); + const { data, error } = await query; + if (error) { + console.error('[API InfosSillons] getAllInfosSillons error:', error); + return { data: null, error }; + } + return { data: data || [], error: null }; + } catch (err) { + console.error('[API InfosSillons] getAllInfosSillons exception:', err); + return { data: null, error: { message: 'Erreur lors du chargement des infos sillons' } }; + } +} + +export async function createInfoSillon(infoData) { + const sanitized = { + titre: infoData.titre?.trim() || '', + description: infoData.description || null, + type: infoData.type || 'information', + ligne_id: infoData.ligne_id || null, + ligne_nom: infoData.ligne_nom || null, + travaux_id: infoData.travaux_id || null, + date_debut: infoData.date_debut || null, + date_fin: infoData.date_fin || null, + actif: infoData.actif !== false, + auto_generated: infoData.auto_generated === true, + region_id: infoData.region_id || null + }; + + if (!sanitized.titre) { + return { data: null, error: { message: 'Le titre est requis' } }; + } + + try { + const { data, error } = await supabase + .from('infos_sillons') + .insert(sanitized) + .select() + .single(); + + if (error) { + console.error('[API InfosSillons] createInfoSillon error:', error); + return { data: null, error }; + } + return { data, error: null }; + } catch (err) { + console.error('[API InfosSillons] createInfoSillon exception:', err); + return { data: null, error: { message: 'Erreur lors de la création' } }; + } +} + +export async function updateInfoSillon(id, infoData) { + if (!id) return { data: null, error: { message: 'ID manquant' } }; + + const sanitized = { + titre: infoData.titre?.trim() || '', + description: infoData.description || null, + type: infoData.type || 'information', + ligne_id: infoData.ligne_id || null, + ligne_nom: infoData.ligne_nom || null, + travaux_id: infoData.travaux_id || null, + date_debut: infoData.date_debut || null, + date_fin: infoData.date_fin || null, + actif: infoData.actif !== false, + auto_generated: infoData.auto_generated === true, + region_id: infoData.region_id || null + }; + + if (!sanitized.titre) { + return { data: null, error: { message: 'Le titre est requis' } }; + } + + try { + const { data, error } = await supabase + .from('infos_sillons') + .update(sanitized) + .eq('id', id) + .select() + .single(); + + if (error) { + console.error('[API InfosSillons] updateInfoSillon error:', error); + return { data: null, error }; + } + return { data, error: null }; + } catch (err) { + console.error('[API InfosSillons] updateInfoSillon exception:', err); + return { data: null, error: { message: 'Erreur lors de la mise à jour' } }; + } +} + +export async function deleteInfoSillon(id) { + if (!id) return { data: null, error: { message: 'ID manquant' } }; + + try { + const { data, error } = await supabase + .from('infos_sillons') + .delete() + .eq('id', id) + .select('id') + .maybeSingle(); + + if (error) { + console.error('[API InfosSillons] deleteInfoSillon error:', error); + return { data: null, error }; + } + + if (!data?.id) { + return { data: null, error: { message: 'Suppression impossible: élément introuvable.' } }; + } + + return { data: { id: data.id, deleted: true }, error: null }; + } catch (err) { + console.error('[API InfosSillons] deleteInfoSillon exception:', err); + return { data: null, error: { message: 'Erreur lors de la suppression' } }; + } +} diff --git a/src/api/updater.js b/src/api/updater.js new file mode 100644 index 0000000..6cb3858 --- /dev/null +++ b/src/api/updater.js @@ -0,0 +1,167 @@ +/** + * Ferrovia Panel - Client API pour le service de mise à jour + */ + +// Résolution intelligente de l'adresse du serveur de mise à jour +export function getUpdaterBaseUrl() { + if (process.env.REACT_APP_UPDATER_URL) { + return process.env.REACT_APP_UPDATER_URL.replace(/\/$/, ''); + } + + if (typeof window !== 'undefined' && window.location) { + const protocol = window.location.protocol || 'http:'; + const hostname = window.location.hostname || 'localhost'; + // Le service updater écoute par défaut sur le port 4001 + return `${protocol}//${hostname}:4001`; + } + + return 'http://localhost:4001'; +} + +/** + * Effectue un appel HTTP avec timeout configurable + */ +async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + ...(options.headers || {}) + } + }); + clearTimeout(timer); + return res; + } catch (err) { + clearTimeout(timer); + if (err.name === 'AbortError') { + throw new Error(`Délai d'attente dépassé (${timeoutMs}ms) pour ${url}`); + } + throw err; + } +} + +/** + * Vérifie si le service daemon de mise à jour (updater.js) est actif et joignable + */ +export async function checkUpdaterHealth() { + const baseUrl = getUpdaterBaseUrl(); + const startTime = Date.now(); + + try { + const res = await fetchWithTimeout(`${baseUrl}/api/health`, { method: 'GET' }, 3500); + const latency = Date.now() - startTime; + + if (res.ok) { + const data = await res.json(); + return { + isOnline: true, + latency, + data, + url: baseUrl, + error: null + }; + } else { + return { + isOnline: false, + latency, + data: null, + url: baseUrl, + error: `Statut HTTP ${res.status}: ${res.statusText}` + }; + } + } catch (err) { + return { + isOnline: false, + latency: null, + data: null, + url: baseUrl, + error: err.message || 'Impossible de joindre le service updater sur le port 4001' + }; + } +} + +/** + * Récupère les informations système locales (version, commit, branche, remote) + */ +export async function fetchSystemInfo() { + const baseUrl = getUpdaterBaseUrl(); + try { + const res = await fetchWithTimeout(`${baseUrl}/api/update/info`, { method: 'GET' }, 5000); + if (!res.ok) { + throw new Error(`Erreur HTTP ${res.status}`); + } + return await res.json(); + } catch (err) { + console.warn('Impossible de charger les infos système via updater:', err); + return { + version: 'v0.1.0', + branch: 'main', + commitHash: '', + commitMessage: '', + error: err.message + }; + } +} + +/** + * Interroge le dépôt Git distant pour vérifier la disponibilité de nouveaux commits + */ +export async function fetchCheckUpdates() { + const baseUrl = getUpdaterBaseUrl(); + const res = await fetchWithTimeout(`${baseUrl}/api/update/check`, { method: 'GET' }, 20000); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.error || `Erreur serveur HTTP ${res.status}`); + } + return await res.json(); +} + +/** + * Lance le pipeline de mise à jour (git pull, npm install, build, PM2) + */ +export async function startUpdatePipeline(options = {}) { + const baseUrl = getUpdaterBaseUrl(); + const res = await fetchWithTimeout(`${baseUrl}/api/update/start`, { + method: 'POST', + body: JSON.stringify(options) + }, 10000); + + if (!res.ok && res.status !== 202) { + const errJson = await res.json().catch(() => ({})); + throw new Error(errJson.error || `Échec du lancement (code ${res.status})`); + } + + return await res.json(); +} + +/** + * Récupère en temps réel le statut et les logs de la mise à jour en cours + */ +export async function pollUpdateStatus() { + const baseUrl = getUpdaterBaseUrl(); + const res = await fetchWithTimeout(`${baseUrl}/api/update/status`, { method: 'GET' }, 5000); + if (!res.ok) { + throw new Error(`Statut introuvable (HTTP ${res.status})`); + } + return await res.json(); +} + +/** + * Réinitialise l'état de l'updater après succès ou échec + */ +export async function resetUpdateState() { + const baseUrl = getUpdaterBaseUrl(); + try { + const res = await fetchWithTimeout(`${baseUrl}/api/update/reset`, { method: 'POST' }, 5000); + return await res.json(); + } catch (err) { + console.warn('Erreur reset update state:', err); + return { success: false, error: err.message }; + } +} diff --git a/src/components/HoraireModal/HoraireModal.jsx b/src/components/HoraireModal/HoraireModal.jsx index 538bcb0..6ec08f9 100644 --- a/src/components/HoraireModal/HoraireModal.jsx +++ b/src/components/HoraireModal/HoraireModal.jsx @@ -386,28 +386,40 @@ const handleSave = async () => { } } - const INP = { width: '100%', padding: '9px 12px', border: '1px solid var(--wcs-gray-300, #d1d5db)', borderRadius: 6, fontSize: 14, boxSizing: 'border-box', outline: 'none', fontFamily: 'inherit', background: 'inherit', color: 'inherit' }; - const LBL = { display: 'block', fontWeight: 600, fontSize: 13, color: 'inherit', marginBottom: 6 }; - const SEC = { fontSize: 13, fontWeight: 700, color: 'inherit', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 12px', paddingBottom: 6, borderBottom: '1px solid var(--wcs-gray-200, #e5e7eb)' }; 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' }; return (
e.stopPropagation()} - style={{ maxWidth: 900, display: 'flex', flexDirection: 'column', maxHeight: '92vh' }} + style={{ maxWidth: 940, display: 'flex', flexDirection: 'column', maxHeight: '92vh' }} > - {/* ── Header ── */} -
-

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

+
+
+
Gestion du sillon
+

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

+
- {/* ── Tabs ── */} +
+
+ Départ + {gareDepartNom || 'Gare inconnue'} + {heureDepart || '--:--'} +
+
→
+
+ Arrivée + {gareArriveeNom || 'Gare inconnue'} + {heureArrivee || '--:--'} +
+
+
{TABS.map(t => ( ))}
- - {/* ── Body (scrollable) ── */} +
{formError && (
⚠️ {formError}
)} - - {/* ══ TAB GÉNÉRAL ══ */} + {activeTab === 'general' && ( -
-
-
- - 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 => ( - - ))} -
+
+
+
Identification
+
+
+ + setNumeroTrain(e.target.value)} placeholder="Ex: TER 12345" /> +
+
+ + + {typeTrainNom && ( +
✓ {typeTrainNom}
)}
-
- - 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 => ( - - ))} -
- )} +
+
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)} />
-
- - setHeureArrivee(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)} /> +
@@ -586,10 +600,10 @@ const handleSave = async () => {
- +
setGareSuggestionsOpenFor(`desservie-${idx}`)} onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === `desservie-${idx}` ? null : prev), 120)} @@ -621,12 +635,12 @@ const handleSave = async () => {
- - updateGareDesservieField(idx, 'heure_arrivee', e.target.value)} /> + + updateGareDesservieField(idx, 'heure_arrivee', e.target.value)} />
- - updateGareDesservieField(idx, 'heure_depart', e.target.value)} /> + + updateGareDesservieField(idx, 'heure_depart', e.target.value)} />
diff --git a/src/components/header/AppHeader.js b/src/components/header/AppHeader.js index 3e2f4ad..8d779c0 100644 --- a/src/components/header/AppHeader.js +++ b/src/components/header/AppHeader.js @@ -44,7 +44,8 @@ const MENU_GROUPS = [ label: 'Diffusion', items: [ { label: "Gestion d'informations", href: '/informations', match: ['/informations'] }, - { label: 'Annonces sonores', href: '/annonces-sonores', match: ['/annonces-sonores'] } + { label: 'Annonces sonores', href: '/annonces-sonores', match: ['/annonces-sonores'] }, + { label: 'Fiches horaires', href: '/fiches-horaires', match: ['/fiches-horaires'] } ] }, { @@ -330,9 +331,10 @@ export default function AppHeader({ isLoggedIn, userName }) { else if (section === 'gares') permSection = 'gares'; else if (section === 'quais') permSection = 'quais'; else if (section === 'annonces-sonores') permSection = 'annonces_sonores'; + else if (section === 'fiches-horaires') permSection = 'fiches_horaires'; else if (section === 'systeme-maj') permSection = 'systeme_maj'; - return hasAccessTo(user, permSection); + return hasAccessTo(user, permSection) || (permSection === 'fiches_horaires' && hasAccessTo(user, 'horaires')); } return true; }); diff --git a/src/components/nav-panel/NavPanel.js b/src/components/nav-panel/NavPanel.js index 10bf14b..b51c470 100644 --- a/src/components/nav-panel/NavPanel.js +++ b/src/components/nav-panel/NavPanel.js @@ -50,6 +50,13 @@ export default function NavPanel() { } + {(hasAccessTo(currentUser, 'fiches_horaires') || hasAccessTo(currentUser, 'horaires')) && + + + Fiches Horaires + + } + {hasAccessTo(currentUser, 'informations') && diff --git a/src/pages/fiches-horaires/FichesHoraires.css b/src/pages/fiches-horaires/FichesHoraires.css new file mode 100644 index 0000000..d4e8e4f --- /dev/null +++ b/src/pages/fiches-horaires/FichesHoraires.css @@ -0,0 +1,685 @@ +/* Styles pour la page Fiches Horaires */ + +.fiches-horaires-page { + padding: 24px 32px 60px; + max-width: 1400px; + margin: 0 auto; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +.fh-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; +} + +.fh-title-area h2 { + font-size: 1.8rem; + font-weight: 700; + color: #1e293b; + margin: 0 0 6px; + display: flex; + align-items: center; + gap: 10px; +} + +.fh-title-area p { + margin: 0; + color: #64748b; + font-size: 0.95rem; +} + +/* Tabs Navigation */ +.fh-tabs-nav { + display: flex; + gap: 8px; + border-bottom: 2px solid #e2e8f0; + margin-bottom: 24px; +} + +.fh-tab-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + font-size: 0.95rem; + font-weight: 600; + color: #64748b; + cursor: pointer; + transition: all 0.2s ease; + margin-bottom: -2px; +} + +.fh-tab-btn:hover { + color: #0f172a; + background: #f8fafc; + border-radius: 6px 6px 0 0; +} + +.fh-tab-btn.active { + color: #d91a5b; + border-bottom-color: #d91a5b; + background: #fff; +} + +/* Filters bar */ +.fh-filter-bar { + display: flex; + gap: 12px; + background: #ffffff; + padding: 16px; + border-radius: 12px; + border: 1px solid #e2e8f0; + margin-bottom: 24px; + align-items: center; + flex-wrap: wrap; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); +} + +.fh-search-input { + flex: 1; + min-width: 220px; + padding: 9px 14px; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 0.9rem; + outline: none; + transition: border-color 0.2s; +} + +.fh-search-input:focus { + border-color: #d91a5b; + box-shadow: 0 0 0 3px rgba(217, 26, 91, 0.15); +} + +.fh-select { + padding: 9px 14px; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 0.9rem; + background: #ffffff; + color: #334155; + outline: none; + cursor: pointer; +} + +.fh-select:focus { + border-color: #d91a5b; +} + +/* Table of Fiches */ +.fh-table-card { + background: #ffffff; + border-radius: 12px; + border: 1px solid #e2e8f0; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); +} + +.fh-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 0.9rem; +} + +.fh-table th { + background: #f8fafc; + padding: 12px 16px; + font-weight: 600; + color: #475569; + border-bottom: 1px solid #e2e8f0; +} + +.fh-table td { + padding: 14px 16px; + border-bottom: 1px solid #f1f5f9; + color: #1e293b; + vertical-align: middle; +} + +.fh-table tr:hover td { + background: #f8fafc; +} + +/* Status Pills */ +.fh-status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 9999px; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: transform 0.15s; +} + +.fh-status-pill:hover { + transform: scale(1.04); +} + +.fh-status-projet { + background: #f1f5f9; + color: #475569; + border-color: #cbd5e1; +} + +.fh-status-en-cours { + background: #fef3c7; + color: #b45309; + border-color: #fde68a; +} + +.fh-status-validee { + background: #dcfce7; + color: #15803d; + border-color: #bbf7d0; +} + +/* Actions in table */ +.fh-actions-cell { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; +} + +.fh-btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: #fff; + color: #475569; + cursor: pointer; + transition: all 0.15s; +} + +.fh-btn-icon:hover { + background: #f1f5f9; + color: #0f172a; + border-color: #cbd5e1; +} + +.fh-btn-icon.primary { + background: #d91a5b; + color: #fff; + border-color: #d91a5b; +} + +.fh-btn-icon.primary:hover { + background: #b5154a; +} + +.fh-btn-icon.danger:hover { + background: #fee2e2; + color: #dc2626; + border-color: #fca5a5; +} + +/* Empty state */ +.fh-empty-state { + text-align: center; + padding: 60px 20px; + color: #64748b; +} + +.fh-empty-icon { + font-size: 3rem; + margin-bottom: 12px; + color: #94a3b8; +} + +/* Modals */ +.fh-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.65); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: 20px; +} + +.fh-modal-content { + background: #ffffff; + border-radius: 16px; + width: 100%; + max-width: 1050px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2), 0 10px 10px -5px rgba(0, 0, 0, 0.1); + overflow: hidden; + animation: fhModalPop 0.2s ease-out; +} + +.fh-modal-pdf { + max-width: 1200px; + height: 92vh; +} + +@keyframes fhModalPop { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.fh-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 24px; + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; +} + +.fh-modal-header h3 { + margin: 0; + font-size: 1.25rem; + color: #1e293b; + display: flex; + align-items: center; + gap: 8px; +} + +.fh-modal-close-btn { + background: transparent; + border: none; + font-size: 1.3rem; + color: #64748b; + cursor: pointer; + width: 32px; + height: 32px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.fh-modal-close-btn:hover { + background: #e2e8f0; + color: #0f172a; +} + +.fh-modal-body { + padding: 24px; + overflow-y: auto; + flex: 1; +} + +.fh-modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; +} + +/* Wizard Steps Indicator */ +.fh-wizard-steps { + display: flex; + justify-content: space-between; + margin-bottom: 24px; + position: relative; +} + +.fh-wizard-steps::before { + content: ''; + position: absolute; + top: 16px; + left: 20px; + right: 20px; + height: 2px; + background: #e2e8f0; + z-index: 1; +} + +.fh-wizard-step { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + background: #fff; + padding: 0 8px; +} + +.fh-step-circle { + width: 32px; + height: 32px; + border-radius: 50%; + background: #f1f5f9; + color: #64748b; + border: 2px solid #cbd5e1; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.85rem; + transition: all 0.2s; +} + +.fh-wizard-step.active .fh-step-circle { + background: #d91a5b; + color: #fff; + border-color: #d91a5b; + box-shadow: 0 0 0 4px rgba(217, 26, 91, 0.2); +} + +.fh-wizard-step.completed .fh-step-circle { + background: #10b981; + color: #fff; + border-color: #10b981; +} + +.fh-step-label { + font-size: 0.78rem; + font-weight: 600; + color: #64748b; +} + +.fh-wizard-step.active .fh-step-label { + color: #d91a5b; +} + +/* Form Styles */ +.fh-form-group { + margin-bottom: 20px; +} + +.fh-form-group label { + display: block; + font-weight: 600; + color: #334155; + margin-bottom: 6px; + font-size: 0.88rem; +} + +.fh-form-input, .fh-form-select, .fh-form-textarea { + width: 100%; + padding: 10px 14px; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 0.9rem; + box-sizing: border-box; + outline: none; + transition: border-color 0.2s; +} + +.fh-form-input:focus, .fh-form-select:focus, .fh-form-textarea:focus { + border-color: #d91a5b; + box-shadow: 0 0 0 3px rgba(217, 26, 91, 0.15); +} + +.fh-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.fh-grid-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 16px; +} + +/* Configuration Tab Cards */ +.fh-config-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); +} + +.fh-config-title { + font-size: 1.1rem; + font-weight: 700; + color: #1e293b; + margin: 0 0 16px; + display: flex; + align-items: center; + gap: 8px; +} + +.fh-color-picker-box { + display: flex; + align-items: center; + gap: 12px; +} + +.fh-color-input { + width: 44px; + height: 40px; + padding: 2px; + border: 1px solid #cbd5e1; + border-radius: 8px; + cursor: pointer; +} + +/* Live Template Preview Widget */ +.fh-preview-widget { + background: #f8fafc; + border: 2px dashed #cbd5e1; + border-radius: 12px; + padding: 16px; + margin-top: 16px; +} + +.fh-preview-header-mockup { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 10px; +} + +.fh-preview-logo-mockup { + font-size: 1.5rem; + font-weight: 800; + padding: 6px 12px; + background: #fff; + border-radius: 6px; +} + +.fh-preview-banner-mockup { + flex: 1; + padding: 10px 16px; + border-radius: 6px; + color: #fff; +} + +.fh-preview-banner-title { + font-size: 1.1rem; + font-weight: 800; + letter-spacing: 0.5px; +} + +.fh-preview-banner-sub { + font-size: 0.75rem; + opacity: 0.9; +} + +.fh-preview-alert-mockup { + padding: 6px 12px; + border-radius: 6px; + color: #fff; + font-size: 0.78rem; + font-weight: 600; + margin-bottom: 12px; +} + +.fh-preview-contact-mockup { + padding: 14px; + border-radius: 8px; + color: #fff; + max-width: 280px; + margin-left: auto; +} + +/* Multi-line Fusion Controls */ +.fh-mode-toggle-group { + display: flex; + gap: 8px; + margin-bottom: 16px; + background: #f1f5f9; + padding: 4px; + border-radius: 10px; + width: fit-content; +} + +.fh-mode-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 8px; + background: transparent; + font-size: 0.88rem; + font-weight: 600; + color: #64748b; + cursor: pointer; + transition: all 0.2s ease; +} + +.fh-mode-btn:hover { + color: #1e293b; +} + +.fh-mode-btn.active { + background: #ffffff; + color: #d91a5b; + box-shadow: 0 1px 4px rgba(0,0,0,0.08); +} + +.fh-merged-lines-container { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 14px; + margin-bottom: 16px; +} + +.fh-merged-chips-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + align-items: center; +} + +.fh-line-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: #ffffff; + border: 1.5px solid #d91a5b; + color: #d91a5b; + padding: 6px 12px; + border-radius: 20px; + font-size: 0.82rem; + font-weight: 600; + box-shadow: 0 1px 2px rgba(217, 26, 91, 0.08); +} + +.fh-chip-order-btn { + background: #f1f5f9; + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 0.75rem; + color: #475569; + padding: 0; + transition: background 0.15s; +} + +.fh-chip-order-btn:hover { + background: #e2e8f0; + color: #0f172a; +} + +.fh-chip-remove-btn { + background: transparent; + border: none; + color: #ef4444; + cursor: pointer; + font-weight: bold; + font-size: 0.95rem; + margin-left: 2px; + padding: 0 2px; + display: flex; + align-items: center; +} + +.fh-chip-remove-btn:hover { + color: #b91c1c; +} + +.fh-line-selector-table { + max-height: 220px; + overflow-y: auto; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #ffffff; + margin-top: 8px; +} + +.fh-line-selector-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid #f1f5f9; + cursor: pointer; + transition: background 0.15s; +} + +.fh-line-selector-item:last-child { + border-bottom: none; +} + +.fh-line-selector-item:hover { + background: #f8fafc; +} + +.fh-line-selector-item.selected { + background: #fdf2f8; +} + +.fh-tag-line-badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: #f1f5f9; + color: #334155; + border: 1px solid #cbd5e1; + padding: 2px 7px; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; +} + diff --git a/src/pages/fiches-horaires/FichesHoraires.jsx b/src/pages/fiches-horaires/FichesHoraires.jsx new file mode 100644 index 0000000..9e9ef70 --- /dev/null +++ b/src/pages/fiches-horaires/FichesHoraires.jsx @@ -0,0 +1,2315 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import NavPanel from '../../components/nav-panel/NavPanel'; +import { getAllLignes } from '../../api/lignes'; +import { getAllServicesAnnuels } from '../../api/servicesAnnuels'; +import { getAllHoraires } from '../../api/horaires'; +import { getAllGares } from '../../api/gares'; +import { getAllRegions } from '../../api/regions'; +import { + getAllFichesHoraires, + saveFicheHoraire, + updateFicheHoraireStatus, + deleteFicheHoraire, + uploadFicheHorairePDF, + generateHarmonizedFileName, + getFichesHorairesTemplatesConfig, + saveFichesHorairesTemplatesConfig, + REGIONAL_PRESETS, + FICHE_STATUTS +} from '../../api/fichesHoraires'; +import { + generateFicheHorairePdf, + isCirculationCar, + compareHoraireDepart, + cleanStationName, + findStationIndex, + formatCirculationDaysCompact +} from '../../utils/ficheHorairePdfGenerator'; +import './FichesHoraires.css'; + +// Helper: normalize string for comparison (removes accents, hyphens, spaces, lowercases) +const normalizeStr = (str) => { + return cleanStationName(str); +}; + +// Helper: Check if a train runs in a regular standard week ("Semaine type") +// Excludes one-off, occasional or exceptional trains (horaires ponctuels) +export const isSemaineTypeHoraire = (h) => { + if (!h) return false; + // Exclure si explicitement ponctuel + if (h.est_ponctuel === true || h.type_horaire === 'ponctuel' || h.type === 'ponctuel') { + return false; + } + const daysStr = String(h.jours_circulation || '').toLowerCase().trim(); + if (daysStr.includes('ponctuel') || daysStr.includes('unique') || daysStr.includes('exceptionnel')) { + return false; + } + // Si date ponctuelle unique (ex: 2025-05-14 ou 14/05/2025) + if (/^\d{4}-\d{2}-\d{2}$/.test(daysStr) || /^\d{2}\/\d{2}\/\d{4}$/.test(daysStr)) { + return false; + } + // Vérifier les jours de circulation booléens + const hasActiveDay = [ + h.circule_lundi, h.circule_mardi, h.circule_mercredi, h.circule_jeudi, + h.circule_vendredi, h.circule_samedi, h.circule_dimanche + ].some(Boolean); + + if (hasActiveDay) return true; + + // Si pas de booléens mais texte de jours réguliers + if (daysStr) { + const regularKeywords = ['lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim', 'tous', 'semaine', 'quotidien', 'fetes', 'fêtes']; + return regularKeywords.some(kw => daysStr.includes(kw)); + } + + return true; +}; + +// Comprehensive matcher: Check if a horaire belongs to a given Line +export const isHoraireMatchingLine = (h, line) => { + if (!h || !line) return false; + + const hLigneId = String(h.ligne_id || h.ligne || ''); + const hLigneNom = String(h.ligne_nom || h.ligne || ''); + const normHLigneNom = normalizeStr(hLigneNom); + + const lineId = String(line.id || ''); + const lineCode = String(line.code_ligne || line.code || line.numero_ligne || ''); + const lineNom = String(line.nom || ''); + const normLineNom = normalizeStr(lineNom); + + // 1. Direct ID / Code match + if (hLigneId && (hLigneId === lineId || (lineCode && hLigneId === lineCode))) return true; + + // 2. Name normalized match + if (normHLigneNom && (normHLigneNom === normLineNom || (lineCode && normHLigneNom === normalizeStr(lineCode)))) return true; + + // 3. Name contains + if (normHLigneNom && normLineNom && (normHLigneNom.includes(normLineNom) || normLineNom.includes(normHLigneNom))) return true; + + // 4. Station overlap match (if train departs or arrives at line stations) + const lineDepart = normalizeStr(line.gare_depart?.nom || ''); + const lineArrivee = normalizeStr(line.gare_arrivee?.nom || ''); + const trainDepart = normalizeStr(h.gare_depart_nom || ''); + const trainArrivee = normalizeStr(h.gare_arrivee_nom || ''); + + if (lineDepart && lineArrivee && trainDepart && trainArrivee) { + if ((trainDepart === lineDepart && trainArrivee === lineArrivee) || (trainDepart === lineArrivee && trainArrivee === lineDepart)) { + return true; + } + } + + return false; +}; + +// Check if a horaire belongs to ANY of the selected lines +export const isHoraireMatchingAnySelectedLine = (h, lines) => { + if (!h || !lines || lines.length === 0) return false; + return lines.some(l => isHoraireMatchingLine(h, l)); +}; + +// Helper: find which selected line a horaire belongs to +export const getHoraireMatchedLine = (h, lines) => { + if (!h || !lines || lines.length === 0) return null; + return lines.find(l => isHoraireMatchingLine(h, l)) || null; +}; + +// Comprehensive matcher: Check if a horaire belongs to a given SA +export const isHoraireMatchingSA = (h, sa, saId) => { + if (!saId || saId === 'all' || !sa) return true; + + const hSAId = String(h.service_annuel_id || h.service_annuel || ''); + const hSANom = String(h.service_annuel_nom || h.service_annuel || ''); + const normHSANom = normalizeStr(hSANom); + + const targetId = String(sa.id || saId); + const targetCode = String(sa.code_service || sa.code || ''); + const targetNom = String(sa.nom || ''); + const normTargetNom = normalizeStr(targetNom); + + // 1. Direct ID / Code match + if (hSAId && (hSAId === targetId || (targetCode && hSAId === targetCode))) return true; + + // 2. Name match + if (normHSANom && (normHSANom === normTargetNom || normHSANom.includes(normTargetNom) || normTargetNom.includes(normHSANom))) return true; + + // 3. Year digits match (e.g. "2025" or "2026") + const saYears = (targetNom.match(/\d{4}/g) || []).join(''); + const hYears = (hSANom.match(/\d{4}/g) || []).join(''); + if (saYears && hYears && saYears === hYears) return true; + + return false; +}; + +export default function FichesHoraires() { + const [activeTab, setActiveTab] = useState('liste'); // 'liste' | 'config' + + // Data lists + const [fiches, setFiches] = useState([]); + const [lignes, setLignes] = useState([]); + const [servicesAnnuels, setServicesAnnuels] = useState([]); + const [allHoraires, setAllHoraires] = useState([]); + const [allGares, setAllGares] = useState([]); + const [regions, setRegions] = useState([]); + const [actionMsg, setActionMsg] = useState(null); + + // Filter state for Tab 1 + const [searchTerm, setSearchTerm] = useState(''); + const [filterLigne, setFilterLigne] = useState('all'); + const [filterSA, setFilterSA] = useState('all'); + const [filterStatut, setFilterStatut] = useState('all'); + + // PDF Viewer Modal + const [viewingPdfFiche, setViewingPdfFiche] = useState(null); + + // Generator Wizard State + const [wizardOpen, setWizardOpen] = useState(false); + const [wizardStep, setWizardStep] = useState(1); + const [wizardMergeMode, setWizardMergeMode] = useState(false); // false = Ligne unique, true = Fusionner plusieurs lignes + const [wizardSelectedLineIds, setWizardSelectedLineIds] = useState([]); // Array of string line IDs in order of merge + const [wizardLineSearch, setWizardLineSearch] = useState(''); + const [wizardLigneId, setWizardLigneId] = useState(''); + const [wizardCustomTitle, setWizardCustomTitle] = useState(''); // Titre personnalisé ex: GARE A > GARE B + const [wizardSAId, setWizardSAId] = useState('all'); + const [wizardDirection, setWizardDirection] = useState('aller'); + const [wizardStatut, setWizardStatut] = useState('Projet (Brouillon)'); + const [wizardOnlySemaineType, setWizardOnlySemaineType] = useState(true); // default true: filter only standard week schedules + const [wizardSelectedTrainIds, setWizardSelectedTrainIds] = useState(new Set()); + const [wizardOrderedTrains, setWizardOrderedTrains] = useState([]); + const [wizardTrainSearch, setWizardTrainSearch] = useState(''); + const [wizardDirectionFilter, setWizardDirectionFilter] = useState('all'); // 'all' | 'aller' | 'retour' | 'car' + const [wizardLineFilter, setWizardLineFilter] = useState('all'); // 'all' | lineId + + // Manual Train Creation Modal inside Wizard + const [showManualTrainModal, setShowManualTrainModal] = useState(false); + const [manualTrainForm, setManualTrainForm] = useState({ + numero_train: '', + type_train: 'TER', + gare_depart_nom: '', + heure_depart: '08:00', + gare_arrivee_nom: '', + heure_arrivee: '09:00', + jours_circulation: 'Lun à Ven', + est_substitution: false + }); + + const [wizardInfoTrafic, setWizardInfoTrafic] = useState( + "INFO TRAFIC TRAVAUX : Les travaux peuvent impacter la circulation des trains. Durant ces périodes, les horaires des trains peuvent être modifiés. Nous vous conseillons de les vérifier avant votre voyage." + ); + const [wizardDisclaimer, setWizardDisclaimer] = useState( + "Horaires donnés à titre indicatif, consultez vos horaires avant départ. N'oubliez pas de vous reporter aux renvois." + ); + const [wizardRenvois, setWizardRenvois] = useState([ + { num: 1, text: "Horaires donnés à titre indicatif." }, + { num: 2, text: "Circule les 1er, 8 et 14 mai et 15 août." }, + { num: 3, text: "Circule les 25 décembre, 1er janvier, 6 avril, 25 mai, 14 juillet et 11 novembre." }, + { num: 4, text: "Circule les 24 et 31 décembre, 30 avril, 7 et 13 mai." } + ]); + const [wizardGeneratedPdf, setWizardGeneratedPdf] = useState(null); // { blob, blobUrl, filename } + const [wizardSaving, setWizardSaving] = useState(false); + const [wizardGeneratingPdf, setWizardGeneratingPdf] = useState(false); + + // Configuration Tab State + const [selectedConfigRegion, setSelectedConfigRegion] = useState('bourgogne-franche-comte'); + const [templatesConfig, setTemplatesConfig] = useState(() => getFichesHorairesTemplatesConfig()); + + // Load all initial data + const loadAllData = async () => { + try { + const results = await Promise.allSettled([ + getAllFichesHoraires(), + getAllLignes(), + getAllServicesAnnuels(), + getAllHoraires(), + getAllGares(), + getAllRegions() + ]); + + const [fRes, lRes, saRes, hRes, gRes, rRes] = results; + + if (fRes.status === 'fulfilled' && fRes.value?.data) setFiches(Array.isArray(fRes.value.data) ? fRes.value.data : []); + if (lRes.status === 'fulfilled' && lRes.value?.data) setLignes(Array.isArray(lRes.value.data) ? lRes.value.data : []); + if (saRes.status === 'fulfilled' && saRes.value?.data) setServicesAnnuels(Array.isArray(saRes.value.data) ? saRes.value.data : []); + if (hRes.status === 'fulfilled' && hRes.value?.data) setAllHoraires(Array.isArray(hRes.value.data) ? hRes.value.data : []); + if (gRes.status === 'fulfilled' && gRes.value?.data) setAllGares(Array.isArray(gRes.value.data) ? gRes.value.data : []); + if (rRes.status === 'fulfilled' && rRes.value?.data) setRegions(Array.isArray(rRes.value.data) ? rRes.value.data : []); + } catch (err) { + console.error('Erreur chargement données fiches horaires:', err); + } + }; + + useEffect(() => { + loadAllData(); + }, []); + + const showToast = (text) => { + setActionMsg(text); + setTimeout(() => setActionMsg(null), 3500); + }; + + // Extract unique available lines from both lignes API and allHoraires records + const availableLignes = useMemo(() => { + const list = [...lignes]; + const knownIds = new Set(list.map(l => String(l.id))); + const knownNames = new Set(list.map(l => normalizeStr(l.nom))); + + allHoraires.forEach(h => { + const hId = h.ligne_id || h.ligne; + const hNom = h.ligne_nom || h.ligne || (hId ? `Ligne ${hId}` : ''); + const normNom = normalizeStr(hNom); + + if (hId && !knownIds.has(String(hId))) { + knownIds.add(String(hId)); + list.push({ id: String(hId), nom: hNom, code_ligne: String(hId) }); + } else if (hNom && normNom && !knownNames.has(normNom)) { + knownNames.add(normNom); + list.push({ id: hNom, nom: hNom, code_ligne: hNom }); + } + }); + return list; + }, [lignes, allHoraires]); + + // Extract unique available SAs from both servicesAnnuels API and allHoraires records + const availableServicesAnnuels = useMemo(() => { + const list = [...servicesAnnuels]; + const knownIds = new Set(list.map(s => String(s.id))); + const knownNames = new Set(list.map(s => normalizeStr(s.nom))); + + allHoraires.forEach(h => { + const sId = h.service_annuel_id || h.service_annuel; + const sNom = h.service_annuel_nom || h.service_annuel || (sId ? `SA ${sId}` : ''); + const normNom = normalizeStr(sNom); + + if (sId && !knownIds.has(String(sId))) { + knownIds.add(String(sId)); + list.push({ id: String(sId), nom: sNom, code_service: String(sId) }); + } else if (sNom && normNom && !knownNames.has(normNom)) { + knownNames.add(normNom); + list.push({ id: sNom, nom: sNom, code_service: sNom }); + } + }); + return list; + }, [servicesAnnuels, allHoraires]); + + // Filtered Fiches for Tab 1 + const filteredFiches = useMemo(() => { + return fiches.filter(f => { + if (filterLigne !== 'all' && String(f.ligne_id) !== String(filterLigne)) return false; + if (filterSA !== 'all' && String(f.service_annuel_id) !== String(filterSA)) return false; + if (filterStatut !== 'all' && f.statut !== filterStatut) return false; + if (searchTerm) { + const q = searchTerm.toLowerCase().trim(); + const t = (f.titre || '').toLowerCase(); + const l = (f.ligne_nom || '').toLowerCase(); + const fn = (f.pdf_filename || '').toLowerCase(); + if (!t.includes(q) && !l.includes(q) && !fn.includes(q)) return false; + } + return true; + }); + }, [fiches, filterLigne, filterSA, filterStatut, searchTerm]); + + // Fast status update in list + const handleStatusChange = async (ficheId, newStatus) => { + const { data, error } = await updateFicheHoraireStatus(ficheId, newStatus); + if (!error && data) { + setFiches(prev => prev.map(f => String(f.id) === String(ficheId) ? { ...f, statut: newStatus } : f)); + showToast(`Statut mis à jour : "${newStatus}"`); + } + }; + + // Delete fiche + const handleDeleteFiche = async (fiche) => { + if (!window.confirm(`Supprimer définitivement la fiche horaire "${fiche.titre || fiche.pdf_filename}" ?`)) return; + const { error } = await deleteFicheHoraire(fiche.id, fiche.pdf_storage_path); + if (!error) { + setFiches(prev => prev.filter(f => String(f.id) !== String(fiche.id))); + showToast('Fiche horaire supprimée avec succès.'); + } + }; + + // --- Wizard Handlers --- + const openNewWizard = () => { + setWizardStep(1); + const firstLigne = availableLignes[0]?.id ? String(availableLignes[0].id) : ''; + setWizardLigneId(firstLigne); + setWizardSelectedLineIds(firstLigne ? [firstLigne] : []); + setWizardMergeMode(false); + setWizardLineSearch(''); + setWizardCustomTitle(''); + setWizardLineFilter('all'); + setWizardSAId('all'); // default to 'all' so all circulations for line are included + setWizardDirection('aller'); + setWizardStatut('Projet (Brouillon)'); + setWizardSelectedTrainIds(new Set()); + setWizardOrderedTrains([]); + setWizardGeneratedPdf(null); + setWizardDirectionFilter('all'); + setWizardOpen(true); + }; + + // Selected Line(s) & SA objects + const selectedLines = useMemo(() => { + if (!wizardMergeMode) { + if (!wizardLigneId) return []; + const l = availableLignes.find(x => String(x.id) === String(wizardLigneId)); + return l ? [l] : []; + } + return wizardSelectedLineIds + .map(id => availableLignes.find(l => String(l.id) === String(id))) + .filter(Boolean); + }, [availableLignes, wizardMergeMode, wizardLigneId, wizardSelectedLineIds]); + + const selectedLine = selectedLines[0] || null; + + const selectedSA = useMemo(() => { + if (!wizardSAId || wizardSAId === 'all') return null; + return availableServicesAnnuels.find(s => String(s.id) === String(wizardSAId)) || null; + }, [availableServicesAnnuels, wizardSAId]); + + // Auto-select first line when list loads if empty + useEffect(() => { + if (wizardOpen && !wizardLigneId && availableLignes.length > 0) { + const firstId = String(availableLignes[0].id); + setWizardLigneId(firstId); + setWizardSelectedLineIds(prev => prev.length === 0 ? [firstId] : prev); + } + }, [wizardOpen, wizardLigneId, availableLignes]); + + // Calculate ordered stations for the chosen line(s) (supports merged multi-line paths) + const orderedGaresForLine = useMemo(() => { + if (selectedLines.length === 0) return []; + + const resultGares = []; + const seenNames = new Set(); + + const addGare = (nom, id = null, fullGare = null) => { + if (!nom) return; + const norm = normalizeStr(nom); + if (seenNames.has(norm)) return; + seenNames.add(norm); + + const foundGare = fullGare || allGares.find(g => (id && String(g.id) === String(id)) || normalizeStr(g.nom) === norm); + resultGares.push({ + id: id || foundGare?.id || nom, + nom: foundGare?.nom || nom, + informations: foundGare?.informations || {}, + guichet: !!foundGare?.informations?.horaires_guichet, + distributeur: !!foundGare?.informations?.distributeur_ter || !!foundGare?.informations?.distributeur_tgv, + assist: !!foundGare?.informations?.service_assistance + }); + }; + + // 1. Pour chaque ligne sélectionnée : Gare de départ d'abord -> Lignes gares ordonnées -> Gare d'arrivée en dernier + selectedLines.forEach(line => { + // A. Gare de départ d'abord + if (line.gare_depart?.nom) { + addGare(line.gare_depart.nom, line.gare_depart.id, line.gare_depart); + } + + // B. Gares intermédiaires ordonnées + if (Array.isArray(line.lignes_gares) && line.lignes_gares.length > 0) { + const sortedLg = [...line.lignes_gares].sort((a, b) => (Number(a.ordre) || 0) - (Number(b.ordre) || 0)); + sortedLg.forEach(lg => { + const gNom = lg.gare?.nom || lg.gare_nom || lg.nom; + const gId = lg.gare?.id || lg.gare_id || lg.id; + addGare(gNom, gId, lg.gare); + }); + } + + if (Array.isArray(line.gares_desservies) && line.gares_desservies.length > 0) { + line.gares_desservies.forEach(gd => { + const gNom = gd.gare_nom || gd.nom; + const gId = gd.gare_id || gd.id; + addGare(gNom, gId); + }); + } + + // C. Gare d'arrivée en dernier + if (line.gare_arrivee?.nom) { + addGare(line.gare_arrivee.nom, line.gare_arrivee.id, line.gare_arrivee); + } + }); + + // 2. Extraire les gares de prolongement (ex: Lyon Part-Dieu connecté à Bourg-en-Bresse) + const trainSource = wizardOrderedTrains.length > 0 ? wizardOrderedTrains : allHoraires.filter(h => isHoraireMatchingAnySelectedLine(h, selectedLines)); + trainSource.forEach(t => { + if (t.gare_depart_nom) addGare(t.gare_depart_nom, t.gare_depart_id); + (t.gares_desservies || []).forEach(g => { + if (g.gare_nom) addGare(g.gare_nom, g.gare_id); + }); + if (t.gare_arrivee_nom) addGare(t.gare_arrivee_nom, t.gare_arrivee_id); + }); + + // Repositionner Lyon Part-Dieu à l'extrémité de Bourg-en-Bresse + const lyonIdx = resultGares.findIndex(g => normalizeStr(g.nom).includes('lyon')); + const bourgIdx = resultGares.findIndex(g => normalizeStr(g.nom).includes('bourg')); + if (lyonIdx !== -1 && bourgIdx !== -1 && Math.abs(lyonIdx - bourgIdx) > 1) { + const [lyonGare] = resultGares.splice(lyonIdx, 1); + const newBourgIdx = resultGares.findIndex(g => normalizeStr(g.nom).includes('bourg')); + resultGares.splice(newBourgIdx + 1, 0, lyonGare); + } + + // 3. If direction is 'retour', reverse order of stations + let finalGares = [...resultGares]; + if (wizardDirection === 'retour') { + finalGares.reverse(); + } + + // 4. Guarantee at least 2 stations + if (finalGares.length === 0) { + finalGares = [ + { nom: selectedLine?.nom || 'BOURG-EN-BRESSE', guichet: true, distributeur: true, assist: true }, + { nom: 'DIJON VILLE', guichet: true, distributeur: true, assist: true } + ]; + } + + return finalGares; + }, [selectedLines, selectedLine, allGares, wizardOrderedTrains, allHoraires, wizardDirection]); + + // When Line(s) & SA are picked, auto-populate candidate circulations + const candidateHoraires = useMemo(() => { + if (selectedLines.length === 0) return []; + return allHoraires.filter(h => { + const matchLigne = isHoraireMatchingAnySelectedLine(h, selectedLines); + if (!matchLigne) return false; + + const matchSA = isHoraireMatchingSA(h, selectedSA, wizardSAId); + if (!matchSA) return false; + + // Filtrer uniquement les horaires de la semaine type (exclure les ponctuels) + if (wizardOnlySemaineType && !isSemaineTypeHoraire(h)) { + return false; + } + + return true; + }).map(h => { + const matchedLine = getHoraireMatchedLine(h, selectedLines); + return { + ...h, + _matchedLineNom: matchedLine?.nom || h.ligne_nom || '', + _matchedLineCode: matchedLine?.code_ligne || matchedLine?.code || '' + }; + }).sort((a, b) => compareHoraireDepart(a, b, orderedGaresForLine)); // Trié par heure de départ (du plus tôt au plus tard) + }, [allHoraires, selectedLines, selectedSA, wizardSAId, wizardOnlySemaineType, orderedGaresForLine]); + + // Count of circulations per SA for the selected line(s) + const saCountsForSelectedLine = useMemo(() => { + const counts = { all: 0 }; + if (selectedLines.length === 0) return counts; + + allHoraires.forEach(h => { + if (isHoraireMatchingAnySelectedLine(h, selectedLines)) { + if (!wizardOnlySemaineType || isSemaineTypeHoraire(h)) { + counts.all = (counts.all || 0) + 1; + availableServicesAnnuels.forEach(sa => { + if (isHoraireMatchingSA(h, sa, sa.id)) { + counts[String(sa.id)] = (counts[String(sa.id)] || 0) + 1; + } + }); + } + } + }); + return counts; + }, [allHoraires, selectedLines, availableServicesAnnuels, wizardOnlySemaineType]); + + // Count of circulations per Line across allHoraires + const lineCountsMap = useMemo(() => { + const map = {}; + availableLignes.forEach(l => { + const c = allHoraires.filter(h => isHoraireMatchingLine(h, l) && (!wizardOnlySemaineType || isSemaineTypeHoraire(h))).length; + map[String(l.id)] = c; + }); + return map; + }, [availableLignes, allHoraires, wizardOnlySemaineType]); + + // Line selection & ordering handlers for multi-line fusion + const handleToggleLineSelection = (lineId) => { + const strId = String(lineId); + setWizardSelectedLineIds(prev => { + if (prev.includes(strId)) { + const next = prev.filter(id => id !== strId); + if (next.length > 0) setWizardLigneId(next[0]); + return next; + } else { + setWizardLigneId(strId); + return [...prev, strId]; + } + }); + setWizardOrderedTrains([]); + setWizardSelectedTrainIds(new Set()); + }; + + const handleMoveLineOrder = (index, delta) => { + const targetIndex = index + delta; + if (targetIndex < 0 || targetIndex >= wizardSelectedLineIds.length) return; + const copy = [...wizardSelectedLineIds]; + const [moved] = copy.splice(index, 1); + copy.splice(targetIndex, 0, moved); + setWizardSelectedLineIds(copy); + if (copy[0]) setWizardLigneId(copy[0]); + }; + + // Step 1 -> Step 2 transition + const handleGoToStep2 = () => { + if (selectedLines.length === 0) { + alert('Veuillez sélectionner au moins une ligne ferroviaire.'); + return; + } + + let trainsToUse = [...candidateHoraires]; + + // If candidateHoraires is empty but allHoraires has trains on these lines + if (trainsToUse.length === 0) { + const allLineTrains = allHoraires.filter(h => isHoraireMatchingAnySelectedLine(h, selectedLines)).map(h => { + const matchedLine = getHoraireMatchedLine(h, selectedLines); + return { + ...h, + _matchedLineNom: matchedLine?.nom || h.ligne_nom || '', + _matchedLineCode: matchedLine?.code_ligne || matchedLine?.code || '' + }; + }); + if (allLineTrains.length > 0) { + trainsToUse = allLineTrains; + } + } + + setWizardOrderedTrains(trainsToUse); + setWizardSelectedTrainIds(new Set(trainsToUse.map(h => h.id))); + setWizardStep(2); + }; + + const handleToggleTrain = (trainId) => { + setWizardSelectedTrainIds(prev => { + const next = new Set(prev); + if (next.has(trainId)) next.delete(trainId); + else next.add(trainId); + return next; + }); + }; + + const handleSelectAllTrains = (selectAll) => { + if (selectAll) { + setWizardSelectedTrainIds(new Set(wizardOrderedTrains.map(t => t.id))); + } else { + setWizardSelectedTrainIds(new Set()); + } + }; + + const handleMoveTrain = (index, direction) => { + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= wizardOrderedTrains.length) return; + const newArr = [...wizardOrderedTrains]; + const [moved] = newArr.splice(index, 1); + newArr.splice(targetIndex, 0, moved); + setWizardOrderedTrains(newArr); + }; + + const handleRemoveTrain = (trainId) => { + setWizardOrderedTrains(prev => prev.filter(t => t.id !== trainId)); + setWizardSelectedTrainIds(prev => { + const next = new Set(prev); + next.delete(trainId); + return next; + }); + }; + + // Add manual train into wizardOrderedTrains + const handleAddManualTrain = () => { + if (!manualTrainForm.numero_train && !manualTrainForm.heure_depart) { + alert('Veuillez au moins renseigner un numéro de circulation ou une heure de départ.'); + return; + } + + const newTrain = { + id: `manual_${Date.now()}`, + numero_train: manualTrainForm.numero_train || `T${Math.floor(1000 + Math.random() * 9000)}`, + type_train: manualTrainForm.type_train || 'TER', + type_train_nom: manualTrainForm.type_train || 'TER', + gare_depart_nom: manualTrainForm.gare_depart_nom || orderedGaresForLine[0]?.nom || 'Gare de départ', + gare_arrivee_nom: manualTrainForm.gare_arrivee_nom || orderedGaresForLine[orderedGaresForLine.length - 1]?.nom || 'Gare d\'arrivée', + heure_depart: manualTrainForm.heure_depart || '08:00', + heure_arrivee: manualTrainForm.heure_arrivee || '09:00', + jours_circulation: manualTrainForm.jours_circulation || 'Lun à Ven', + est_substitution: !!manualTrainForm.est_substitution, + gares_desservies: [] + }; + + setWizardOrderedTrains(prev => [...prev, newTrain]); + setWizardSelectedTrainIds(prev => new Set([...prev, newTrain.id])); + setShowManualTrainModal(false); + setManualTrainForm({ + numero_train: '', + type_train: 'TER', + gare_depart_nom: '', + heure_depart: '08:00', + gare_arrivee_nom: '', + heure_arrivee: '09:00', + jours_circulation: 'Lun à Ven', + est_substitution: false + }); + showToast(`Circulation ${newTrain.numero_train} ajoutée avec succès !`); + }; + + // Helper to get direction of a circulation + const getCirculationDirection = (circ, orderedGares = []) => { + if (!circ) return 'aller'; + if (circ.direction === 'retour' || circ.sens === 'retour') return 'retour'; + if (circ.direction === 'aller' || circ.sens === 'aller') return 'aller'; + + if (Array.isArray(orderedGares) && orderedGares.length >= 2) { + const originStation = { id: circ.gare_depart_id, nom: circ.gare_depart_nom }; + const destStation = { id: circ.gare_arrivee_id, nom: circ.gare_arrivee_nom }; + + let depIdx = findStationIndex(originStation, orderedGares); + let arrIdx = findStationIndex(destStation, orderedGares); + + const gdList = Array.isArray(circ.gares_desservies) ? circ.gares_desservies : []; + if (depIdx === -1 && gdList.length > 0) { + for (const s of gdList) { + const idx = findStationIndex(s, orderedGares); + if (idx !== -1) { depIdx = idx; break; } + } + } + if (arrIdx === -1 && gdList.length > 0) { + for (let i = gdList.length - 1; i >= 0; i--) { + const idx = findStationIndex(gdList[i], orderedGares); + if (idx !== -1) { arrIdx = idx; break; } + } + } + + if (depIdx !== -1 && arrIdx !== -1 && depIdx !== arrIdx) { + return depIdx < arrIdx ? 'aller' : 'retour'; + } + if (depIdx !== -1) { + return depIdx <= Math.floor(orderedGares.length / 2) ? 'aller' : 'retour'; + } + if (arrIdx !== -1) { + return arrIdx >= Math.floor(orderedGares.length / 2) ? 'aller' : 'retour'; + } + } + return 'aller'; + }; + + // Filter displayed trains in Step 2 table + const displayedWizardTrains = useMemo(() => { + return wizardOrderedTrains.filter(t => { + const dir = getCirculationDirection(t, orderedGaresForLine); + if (wizardDirectionFilter === 'aller' && dir !== 'aller') return false; + if (wizardDirectionFilter === 'retour' && dir !== 'retour') return false; + if (wizardDirectionFilter === 'car' && !isCirculationCar(t)) return false; + + if (wizardLineFilter !== 'all') { + const matched = getHoraireMatchedLine(t, selectedLines); + if (String(matched?.id) !== String(wizardLineFilter) && String(t.ligne_id) !== String(wizardLineFilter)) { + return false; + } + } + + if (wizardTrainSearch) { + const q = wizardTrainSearch.toLowerCase().trim(); + const num = String(t.numero_train || '').toLowerCase(); + const dep = String(t.gare_depart_nom || '').toLowerCase(); + const arr = String(t.gare_arrivee_nom || '').toLowerCase(); + if (!num.includes(q) && !dep.includes(q) && !arr.includes(q)) return false; + } + return true; + }); + }, [wizardOrderedTrains, wizardTrainSearch, wizardDirectionFilter, wizardLineFilter, orderedGaresForLine, selectedLines]); + + // Trigger PDF Generation in Step 4 + const handleGeneratePdfPreview = async () => { + const activeTrains = wizardOrderedTrains.filter(t => wizardSelectedTrainIds.has(t.id)); + if (activeTrains.length === 0) { + if (wizardOrderedTrains.length > 0) { + // Auto-select all if none selected + setWizardSelectedTrainIds(new Set(wizardOrderedTrains.map(t => t.id))); + } else { + alert('Veuillez ajouter ou sélectionner au moins une circulation.'); + return; + } + } + + setWizardGeneratingPdf(true); + try { + const finalActiveTrains = wizardOrderedTrains.filter(t => wizardSelectedTrainIds.has(t.id)); + const trainsToRender = finalActiveTrains.length > 0 ? finalActiveTrains : wizardOrderedTrains; + + const primaryLine = selectedLines[0] || selectedLine || { nom: 'LIGNE' }; + const mergedLineNom = selectedLines.map(l => l.nom).join(' + ') || primaryLine?.nom || 'LIGNE'; + const mergedLineCode = selectedLines.map(l => l.code_ligne || l.code || l.id).join('-') || primaryLine?.code_ligne || '01'; + const mergedLineObj = { + ...primaryLine, + nom: mergedLineNom, + code_ligne: mergedLineCode, + region_id: primaryLine?.region_id || 'bourgogne-franche-comte' + }; + + const regionId = primaryLine?.region_id || 'bourgogne-franche-comte'; + const regionConfig = templatesConfig[regionId] || templatesConfig['bourgogne-franche-comte'] || REGIONAL_PRESETS['bourgogne-franche-comte']; + + if (wizardDirection === 'les_deux') { + // Gares sens Aller et sens Retour + const garesAller = [...orderedGaresForLine]; + const garesRetour = [...orderedGaresForLine].reverse(); + + // Partition des circulations + let trainsAller = trainsToRender.filter(t => getCirculationDirection(t, garesAller) === 'aller'); + let trainsRetour = trainsToRender.filter(t => getCirculationDirection(t, garesAller) === 'retour'); + + // Si une des directions est vide, on prend l'ensemble + if (trainsAller.length === 0 && trainsRetour.length > 0) trainsAller = trainsToRender; + if (trainsRetour.length === 0 && trainsAller.length > 0) trainsRetour = trainsToRender; + if (trainsAller.length === 0 && trainsRetour.length === 0) { + trainsAller = trainsToRender; + trainsRetour = trainsToRender; + } + + const getRouteTitle = (direction) => { + const gares = direction === 'retour' ? [...orderedGaresForLine].reverse() : orderedGaresForLine; + if (gares.length >= 2) { + return `${gares[0]?.nom || ''} > ${gares[gares.length - 1]?.nom || ''}`; + } + if (primaryLine?.gare_depart?.nom && primaryLine?.gare_arrivee?.nom) { + return direction === 'retour' + ? `${primaryLine.gare_arrivee.nom} > ${primaryLine.gare_depart.nom}` + : `${primaryLine.gare_depart.nom} > ${primaryLine.gare_arrivee.nom}`; + } + return mergedLineNom; + }; + + const titleAller = wizardCustomTitle ? `${wizardCustomTitle} (Sens Aller)` : getRouteTitle('aller'); + const titleRetour = wizardCustomTitle ? `${wizardCustomTitle} (Sens Retour)` : getRouteTitle('retour'); + + const harmonizedName = generateHarmonizedFileName( + `${mergedLineNom}_ALLER_RETOUR`, + mergedLineCode, + selectedSA?.code_service || selectedSA?.nom || wizardSAId || 'SA' + ); + + const pdfResult = await generateFicheHorairePdf({ + ligne: mergedLineObj, + serviceAnnuel: selectedSA || { nom: wizardSAId === 'all' ? 'Service Annuel' : wizardSAId }, + sections: [ + { + title: titleAller, + circulations: trainsAller, + orderedGares: garesAller, + customRenvois: wizardRenvois + }, + { + title: titleRetour, + circulations: trainsRetour, + orderedGares: garesRetour, + customRenvois: wizardRenvois + } + ], + config: regionConfig, + customRenvois: wizardRenvois, + infoTraficText: wizardInfoTrafic, + notesDisclaimer: wizardDisclaimer + }); + + setWizardGeneratedPdf({ + isBothDirections: true, + blob: pdfResult.toBlob(), + blobUrl: pdfResult.toBlobUrl(), + filename: harmonizedName, + circulationsCount: trainsToRender.length, + garesCount: orderedGaresForLine.length, + allerCount: trainsAller.length, + retourCount: trainsRetour.length + }); + } else { + // Sens unique (aller ou retour) + const harmonizedName = generateHarmonizedFileName( + mergedLineNom, + mergedLineCode, + selectedSA?.code_service || selectedSA?.nom || wizardSAId || 'SA' + ); + + const pdfResult = await generateFicheHorairePdf({ + ligne: mergedLineObj, + serviceAnnuel: selectedSA || { nom: wizardSAId === 'all' ? 'Service Annuel' : wizardSAId }, + circulations: trainsToRender, + orderedGares: orderedGaresForLine, + config: regionConfig, + customRenvois: wizardRenvois, + customTitle: wizardCustomTitle, + infoTraficText: wizardInfoTrafic, + notesDisclaimer: wizardDisclaimer + }); + + setWizardGeneratedPdf({ + isBothDirections: false, + blob: pdfResult.toBlob(), + blobUrl: pdfResult.toBlobUrl(), + filename: harmonizedName, + circulationsCount: trainsToRender.length, + garesCount: orderedGaresForLine.length + }); + } + + setWizardStep(4); + } catch (err) { + console.error('Erreur génération PDF fiche horaire:', err); + alert(`Erreur lors de la génération du PDF : ${err?.message || err}`); + } finally { + setWizardGeneratingPdf(false); + } + }; + + // Save & Upload to Supabase Storage Bucket Fiches_Horaires + const handleSaveAndUploadFiche = async () => { + if (!wizardGeneratedPdf) return; + setWizardSaving(true); + try { + const primaryLine = selectedLines[0] || selectedLine || {}; + const mergedLineNom = selectedLines.map(l => l.nom).join(' + ') || primaryLine?.nom || 'Ligne'; + const mergedLineCode = selectedLines.map(l => l.code_ligne || l.code || l.id).join('-') || primaryLine?.code_ligne || ''; + const mergedLineIds = selectedLines.map(l => String(l.id)).join(','); + + const getRouteLabel = (direction) => { + if (wizardCustomTitle) return wizardCustomTitle; + const gares = direction === 'retour' ? [...orderedGaresForLine].reverse() : orderedGaresForLine; + if (gares.length >= 2) { + return `${gares[0]?.nom || ''} > ${gares[gares.length - 1]?.nom || ''}`; + } + if (primaryLine?.gare_depart?.nom && primaryLine?.gare_arrivee?.nom) { + return direction === 'retour' + ? `${primaryLine.gare_arrivee.nom} > ${primaryLine.gare_depart.nom}` + : `${primaryLine.gare_depart.nom} > ${primaryLine.gare_arrivee.nom}`; + } + return mergedLineNom; + }; + + // 1. Upload unified PDF to Supabase Storage + const uploadRes = await uploadFicheHorairePDF(wizardGeneratedPdf.blob, wizardGeneratedPdf.filename); + + const ficheTitle = wizardGeneratedPdf.isBothDirections + ? `${getRouteLabel('aller')} (Aller & Retour) - ${selectedSA?.nom || wizardSAId || 'SA'}` + : `${getRouteLabel(wizardDirection)} - ${selectedSA?.nom || wizardSAId || 'SA'}`; + + // 2. Save Fiche record + const ficheRecord = { + titre: ficheTitle, + ligne_id: mergedLineIds || wizardLigneId, + ligne_nom: mergedLineNom, + code_ligne: mergedLineCode, + service_annuel_id: wizardSAId !== 'all' ? wizardSAId : null, + service_annuel_nom: selectedSA?.nom || (wizardSAId === 'all' ? 'Tous les SA' : wizardSAId), + code_service_annuel: selectedSA?.code_service || selectedSA?.nom || 'SA', + direction: wizardDirection, + statut: wizardStatut, + pdf_url: uploadRes.url, + pdf_filename: wizardGeneratedPdf.filename, + pdf_storage_path: uploadRes.path, + circulations_count: wizardGeneratedPdf.circulationsCount, + gares_count: wizardGeneratedPdf.garesCount, + region_id: primaryLine?.region_id || 'bourgogne-franche-comte', + config: { + infoTraficText: wizardInfoTrafic, + renvois: wizardRenvois, + isMergedLines: selectedLines.length > 1, + mergedLines: selectedLines.map(l => ({ id: l.id, nom: l.nom, code: l.code_ligne || l.code })) + } + }; + + const { data: saved, error } = await saveFicheHoraire(ficheRecord); + if (error) { + console.warn('Avertissement sauvegarde DB fiche horaire (sauvegardé en local):', error); + } + + showToast(`Fiche horaire "${wizardGeneratedPdf.filename}" enregistrée et archivée avec succès !`); + if (saved) { + setFiches(prev => [saved, ...prev.filter(f => String(f.id) !== String(saved.id))]); + } else { + loadAllData(); + } + + setWizardOpen(false); + } catch (err) { + console.error('Erreur enregistrement fiche horaire:', err); + alert(`Erreur lors de l'enregistrement : ${err?.message || err}`); + } finally { + setWizardSaving(false); + } + }; + + // --- Configuration Tab Handlers --- + const activeRegionConfig = useMemo(() => { + return templatesConfig[selectedConfigRegion] || REGIONAL_PRESETS[selectedConfigRegion] || REGIONAL_PRESETS['bourgogne-franche-comte']; + }, [templatesConfig, selectedConfigRegion]); + + const handleUpdateConfigField = (field, value) => { + setTemplatesConfig(prev => ({ + ...prev, + [selectedConfigRegion]: { + ...activeRegionConfig, + [field]: value + } + })); + }; + + const handleUpdateContactField = (contactField, value) => { + setTemplatesConfig(prev => ({ + ...prev, + [selectedConfigRegion]: { + ...activeRegionConfig, + contact: { + ...(activeRegionConfig.contact || {}), + [contactField]: value + } + } + })); + }; + + const handleResetToPreset = () => { + const preset = REGIONAL_PRESETS[selectedConfigRegion] || REGIONAL_PRESETS['bourgogne-franche-comte']; + setTemplatesConfig(prev => ({ + ...prev, + [selectedConfigRegion]: { ...preset } + })); + showToast(`Template réinitialisé au modèle d'origine pour ${preset.regionName || selectedConfigRegion}.`); + }; + + const handleSaveTemplates = () => { + saveFichesHorairesTemplatesConfig(templatesConfig); + showToast('Configuration des templates enregistrée avec succès !'); + }; + + return ( +
+
+
+ + {/* Toast Notification */} + {actionMsg && ( +
+ + {actionMsg} +
+ )} + + {/* Header */} +
+
+

+ + Fiches Horaires +

+

Générez des fiches horaires PDF conformes au design régional officiel, gérez leur statut de production et publiez-les.

+
+
+ + Générer une Fiche Horaire + +
+
+ + {/* Tabs Switcher */} +
+ + +
+ + {/* ========================================================================= */} + {/* TAB 1: LISTE DES FICHES HORAIRES */} + {/* ========================================================================= */} + {activeTab === 'liste' && ( +
+ {/* Filters Bar */} +
+ setSearchTerm(e.target.value)} + /> + + + + + + +
+ + {/* Table */} +
+ {filteredFiches.length === 0 ? ( +
+
📄
+

Aucune fiche horaire trouvée

+

Cliquez sur "Générer une Fiche Horaire" pour créer votre premier document PDF.

+
+ ) : ( + + + + + + + + + + + + + + {filteredFiches.map((fiche) => { + return ( + + + + + + + + + + ); + })} + +
Fiche & Nom du fichierLigneService AnnuelStatut de productionDétailsMis à jourActions
+
{fiche.titre}
+
+ {fiche.pdf_filename || 'fiche_horaire.pdf'} +
+
+ + {fiche.ligne_nom || '—'} + + + + {fiche.service_annuel_nom || '—'} + + + + +
+ 🚆 {fiche.circulations_count || 0} circulations • 🚉 {fiche.gares_count || 0} arrêts +
+
+
+ {new Date(fiche.updated_at || fiche.created_at).toLocaleDateString('fr-FR')} +
+
+
+ {fiche.pdf_url && ( + + )} + {fiche.pdf_url && ( + + + + )} + +
+
+ )} +
+
+ )} + + {/* ========================================================================= */} + {/* TAB 2: CONFIGURATION DES TEMPLATES */} + {/* ========================================================================= */} + {activeTab === 'config' && ( +
+
+ {/* Left Config Column */} +
+
+

+ + Sélection de la Région +

+
+ + +
+
+ + Réinitialiser au modèle régional + +
+
+ +
+

+ + Couleurs de la Fiche Horaire +

+
+
+ +
+ handleUpdateConfigField('primaryColor', e.target.value)} + /> + handleUpdateConfigField('primaryColor', e.target.value)} + /> +
+
+ +
+ +
+ handleUpdateConfigField('alertBgColor', e.target.value)} + /> + handleUpdateConfigField('alertBgColor', e.target.value)} + /> +
+
+
+ +
+
+ + handleUpdateConfigField('brandName', e.target.value)} + /> +
+
+ + handleUpdateConfigField('networkLogoText', e.target.value)} + /> +
+
+
+ +
+

+ + Informations de Contact & Relation Client +

+
+
+ + handleUpdateContactField('title', e.target.value)} + /> +
+
+ + handleUpdateContactField('website', e.target.value)} + /> +
+
+
+
+ + handleUpdateContactField('phoneName', e.target.value)} + /> +
+
+ + handleUpdateContactField('phoneNumber', e.target.value)} + /> +
+
+ + handleUpdateContactField('phoneHours', e.target.value)} + /> +
+
+
+
+ + handleUpdateContactField('appName', e.target.value)} + /> +
+
+ + handleUpdateContactField('appWebsite', e.target.value)} + /> +
+
+
+
+ + {/* Right Live Mockup Column */} +
+
+

+ + Aperçu Direct du Rendu Graphique +

+
+
+
+ {activeRegionConfig.networkLogoText || 'mobigo'} +
+
+
BOURG-EN-BRESSE > DIJON
+
HORAIRES VALABLES DU 14 DÉCEMBRE AU 12 DÉCEMBRE
+
+
+ +
+ ⚠️ INFO TRAFIC TRAVAUX : Les travaux peuvent impacter la circulation... +
+ +
+
+ Horaires donnés à titre indicatif, consultez vos horaires avant départ. +
+
+ [ Matrice des horaires avec pictos services, guichets, renvois ❶ ❷ ❸ et vélos 🚲 ] +
+
+ +
+
Nous contacter :
+
💻 {activeRegionConfig.contact?.title}
+
📞 {activeRegionConfig.contact?.phoneNumber} ({activeRegionConfig.contact?.phoneHours})
+
📱 {activeRegionConfig.contact?.appWebsite}
+
+
+ +
+ + Enregistrer la Configuration + +
+
+
+
+
+ )} + + {/* ========================================================================= */} + {/* MODAL: PDF VIEWER */} + {/* ========================================================================= */} + {viewingPdfFiche && ( +
setViewingPdfFiche(null)}> +
e.stopPropagation()}> + +
+