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 (
Trajet
- -Générez des fiches horaires PDF conformes au design régional officiel, gérez leur statut de production et publiez-les.
+Cliquez sur "Générer une Fiche Horaire" pour créer votre premier document PDF.
+| Fiche & Nom du fichier | +Ligne | +Service Annuel | +Statut de production | +Détails | +Mis à jour | +Actions | +
|---|---|---|---|---|---|---|
|
+ {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 && (
+
+
+ |
+
+ Vous pouvez ajouter manuellement des trains ou autocars pour composer votre fiche horaire. +
+| + | Sens | +N° Circulation / Type | + {selectedLines.length > 1 &&Ligne d'origine | } +Origine > Destination | +Départ / Arrivée | +Jours | +Actions / Ordre | +
|---|---|---|---|---|---|---|---|
| + handleToggleTrain(train.id)} + style={{ width: 18, height: 18, cursor: 'pointer' }} + /> + | ++ + {isAller ? 'Aller ➔' : 'Retour ⬅'} + + | ++ {train.numero_train || '—'} + {isCar ? ( + 🚌 CAR + ) : ( + 🚆 {train.type_train_nom || train.type_train || 'TER'} + )} + | + {selectedLines.length > 1 && ( ++ + {train._matchedLineCode || train._matchedLineNom || 'Ligne'} + + | + )} ++ {train.gare_depart_nom || '—'} > {train.gare_arrivee_nom || '—'} + | ++ {train.heure_depart || '—'} → {train.heure_arrivee || '—'} + | ++ + {formatCirculationDaysCompact(train)} + + | +
+
+
+
+
+
+ |
+
Importez facilement vos grilles horaires au format .csv, .xls ou .xlsx
+{importSourceMode === 'gtfs' ? 'Analysez un flux ou une archive GTFS, inspectez les lignes et importez les circulations par Service Annuel' : 'Importez facilement vos grilles horaires au format .csv, .xls ou .xlsx'}
routes.txt, trips.txt, stop_times.txt, stops.txt, calendar.txt, et calendar_dates.txt.
+ | Badge | +Code / ID | +Nom court | +Nom long | +Type | +Circulations | +Action | +
|---|---|---|---|---|---|---|
| + + {r.route_short_name || r.route_id} + + | +{r.route_id} |
+ {r.route_short_name || '—'} | +{r.route_long_name || '—'} | +{typeName} | +{tripsCount} circulation(s) | +
+ |
+
| Trip ID | +Ligne (Route ID) | +Direction / Destination | +Service ID | +Arrêts | +
|---|---|---|---|---|
{t.trip_id} |
+ {t.route_id} | +{t.trip_headsign || (t.direction_id === '1' ? 'Retour' : 'Aller')} | +{t.service_id} | +{stopsCount} arrêt(s) | +
| Stop ID | +Nom d'arrêt | +Code | +Coordonnées | +Correspondance Base | +
|---|---|---|---|---|
{s.stop_id} |
+ {s.stop_name || '—'} | +{s.stop_code || '—'} | +{s.stop_lat}, {s.stop_lon} | +
+ {matchedGare ? (
+
+ |
+
| Service ID | +Jours actifs | +Période de validité | +Exceptions | +
|---|---|---|---|
| {c.service_id} | +
+
+ {['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map((dayKey, idx) => {
+ const labels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'];
+ const active = c[dayKey] === '1';
+ return (
+
+ {labels[idx]}
+
+ );
+ })}
+
+ |
+ {formatGTFSDate(c.start_date)} → {formatGTFSDate(c.end_date)} | +{exceptions} exception(s) | +
| Période de circulation identifiée | +Horaires | +Service Annuel associé | +Détails de création (si nouveau) | +
|---|---|---|---|
| + {group.start && group.end ? ( + {group.start} → {group.end} + ) : ( + Dates générales / Non spécifiées + )} + | ++ {group.rowIndices.length} horaire(s) + | ++ + | +
+ {isNew && !globalServiceSelection ? (
+
+ setServiceFormByWindow(prev => ({
+ ...prev,
+ [group.key]: { ...(prev[group.key] || {}), nom: e.target.value }
+ }))}
+ placeholder={group.start && group.end ? `SA ${group.start} → ${group.end}` : 'Nom du nouveau service'}
+ />
+ setServiceFormByWindow(prev => ({
+ ...prev,
+ [group.key]: { ...(prev[group.key] || {}), code: e.target.value }
+ }))}
+ placeholder={`SA-${String(idx + 1).padStart(2, '0')}`}
+ />
+
+ ) : (
+
+ |
+
| # | +N° Train | +Trajet | +Service Annuel | +Jours observés | +Pattern estimé | +Confiance | +Projection SA | +
|---|---|---|---|---|---|---|---|
| {idx + 1} | ++ {row.numero_train || '-'} + {est.isSubstitution ? ( + + 🚌 SUBSTITUTION + + ) : est.isPreciseFreq ? ( + + 📐 FRÉQUENCE + + ) : est.isPunctual ? ( + + ⚡ PONCTUEL + + ) : null} + | ++ {row.gare_depart_nom} → {row.gare_arrivee_nom} + | +
+ {row.service_annuel_nom || 'Non associé'}
+ {est.saStart && est.saEnd && (
+ {est.saStart} → {est.saEnd}
+ )}
+ |
+
+ {est.isSubstitution ? (
+
+ 🚌 {est.frequency?.suggestedMotif || 'Travaux'} ({est.observedDatesCount} j. obs.)
+
+ ) : est.isPreciseFreq ? (
+
+
+ ) : est.isPunctual ? (
+
+ ⚡ {est.observedDatesCount} date(s) spécifique(s)
+
+ ) : (
+
+ 📐 {est.patternName}
+
+
+ {days.map(d => (
+ 0 ? d.circ/d.tot : 0)*100)}%)`}
+ >
+ {d.l}
+
+ ))}
+
+
+ {days.map(d => (
+ 0 ? d.circ/d.tot : 0)*100)}%)`}
+ >
+ {d.l}
+
+ ))}
+
+ )}
+ |
+
+
+
+ {est.frequency.detail}
+
+ )}
+ |
+ + {est.isSubstitution ? ( + 🚌 Substitution + ) : est.isPunctual ? ( + ⚡ Ponctuel + ) : ( + + {est.confidence === 'Forte' ? '● Forte' : est.confidence === 'Moyenne' ? '◐ Moyenne' : '○ Faible'} + + )} + | ++ {est.isSubstitution ? ( + + {est.observedDatesCount} j. + → + {est.extrapolatedSaActiveCount} j. + + ({est.frequency?.subMode === 'periode' ? 'Période travaux' : 'SA'}) + + + ) : est.isPunctual ? ( + {est.observedDatesCount} j. (Dates exactes) + ) : ( + <> + {est.observedDatesCount} j. + → + {est.extrapolatedSaActiveCount} j. + ({est.saDaysCount} j. SA) + > + )} + | +
| # | +N° Train | +Trajet | +Mode de circulation | +Jours & Dates de circulation | +Préréglages & Édition | +Résultat SA | +
|---|---|---|---|---|---|---|
| {idx + 1} | ++ {row.numero_train || '-'} + {dec.mode === 'substitution' || dec.est_substitution ? ( + + 🚌 Substitution + + ) : dec.mode === 'precise_freq' ? ( + + 📐 Fréquence fine + + ) : est?.isPunctual ? ( + + ⚡ Détecté ponctuel + + ) : null} + | +{row.gare_depart_nom} → {row.gare_arrivee_nom} | +
+
+
+
+
+
+
+
+ |
+
+ {dec.mode === 'hebdo' && (
+
+ {daysList.map(d => (
+
+ ))}
+
+ )}
+
+ {dec.mode === 'precise_freq' && (
+
+
+ )}
+
+ {dec.mode === 'substitution' && (
+
+
+ {dec.freqType === 'intervalle_jours' && (
+
+
+ Tous les
+ setRowFreqType(idx, dec.freqType, { intervalDays: Math.max(1, parseInt(e.target.value, 10) || 1) })}
+ />
+ jours
+
+ )}
+ {dec.freqType === 'periode' && (
+
+ setRowFreqType(idx, dec.freqType, { periodeStart: e.target.value })}
+ />
+ →
+ setRowFreqType(idx, dec.freqType, { periodeEnd: e.target.value })}
+ />
+
+ )}
+
+ {daysList.map(d => (
+
+ ))}
+
+
+
+ )}
+
+ {dec.mode === 'ponctuel' && (
+
+ setRowSubstitution(idx, true, e.target.value, dec.subMode, dec.periodeStart, dec.periodeEnd)}
+ />
+
+
+ {dec.subMode === 'periode' && (
+
+ Période travaux :
+ setRowSubstitution(idx, true, dec.motif_substitution, 'periode', e.target.value, dec.periodeEnd)}
+ />
+ →
+ setRowSubstitution(idx, true, dec.motif_substitution, 'periode', dec.periodeStart, e.target.value)}
+ />
+
+ )}
+ {dec.subMode !== 'ponctuel' ? (
+
+ {daysList.map(d => (
+
+ ))}
+
+ ) : (
+ ⚡ Dates spécifiques de travaux ({est?.observedDatesCount || 0} j.)
+ )}
+
+
+ ⚡ {est?.observedDatesCount || 0} date(s) du fichier
+
+
+
+ )}
+
+ {dec.mode === 'manuel' && (
+
+
+ 📅 {(dec.customDates || []).length} date(s) manuelle(s)
+
+
+
+ )}
+ |
+
+ {dec.mode === 'hebdo' ? (
+
+
+
+
+
+
+
+ ) : dec.mode === 'precise_freq' ? (
+
+
+
+
+
+
+ ) : dec.mode === 'substitution' ? (
+
+
+
+
+
+ ) : (
+
+ )}
+ |
+ + + {activeCount} j. + + + {dec.mode === 'ponctuel' ? '(fichier)' : (dec.mode === 'substitution' && dec.subMode === 'periode' ? '(Travaux)' : `(${est?.saDaysCount || 0} j. SA)`)} + + | +
+ Maintenance automatisée, synchronisation Git, recompilation et gestion de processus +
+{version}
+ {/* Alerte si le service updater est hors ligne */} + {!serviceHealth.isOnline && ( +
+ Pour permettre à l'application d'exécuter les commandes système (Git pull, NPM install, Build, PM2),
+ le service autonome updater.js doit tourner en arrière-plan sur le serveur.
+
node updater.js
+
+ pm2 start updater.js --name "ferrovia-updater"
+
+ Votre système semble à jour. Vous pouvez forcer une vérification pour synchroniser avec le dépôt.
-+ Vérifiez la présence de nouvelles versions ou de correctifs publiés sur le dépôt distant. +
+ {checkError && ( +Recherche de mises à jour sur le serveur Git distant...
++ Interrogation du dépôt Git distant (fetch & diff)... +
Cette action va exécuter `git pull`, puis recompiler l'application. Le panel sera temporairement indisponible.
+ Votre système est parfaitement à jour ! +
+ Aucun nouveau commit n'a été détecté sur la branche {systemInfo.branch || 'main'}.
+
+ Des modifications ont été publiées sur la branche {updateCheckResult.branch}.
+
Mise à jour en cours sur le serveur...
-- Étape : {stepName || "Initialisation..."} -
-Une erreur critique est survenue durant la mise à jour.
-Consultez les logs du serveur (PM2) ou vérifiez les permissions Git.
-Mise à jour compilée et déployée avec succès !
-L'application va se recharger automatiquement pour appliquer les changements...
++ Les sources ont été synchronisées, les dépendances installées et l'application a été recompilée. +
+ {countdown !== null && ( ++ Rechargement automatique dans {countdown} seconde(s)... +
+ )} ++ {errorDetails || "Consultez le terminal ci-dessous pour identifier la commande ayant échoué."} +
+Région ciblée: {scopedRegionId}
}Récupération des tarifs...
+ Définissez le prix des billets selon des tranches de distance. + {scopedRegionId ? ` Configuration appliquée à la région ${scopedRegionId}.` : ' Configuration globale.'} +
+Chargement des paliers kilométriques...
+| Distance min (km) | +Distance max (km) | +Prix billet (€) | +Actions | +
|---|---|---|---|
| + updatePalier(palier.id, 'distance_min_km', e.target.value === '' ? '' : Number(e.target.value))} + disabled={!canManagePaliers} + /> + | ++ updatePalier(palier.id, 'distance_max_km', e.target.value === '' ? '' : Number(e.target.value))} + disabled={!canManagePaliers} + /> + | ++ updatePalier(palier.id, 'prix_billet', e.target.value === '' ? '' : Number(e.target.value))} + disabled={!canManagePaliers} + /> + | +
+ |
+
| + Aucun palier défini. + | +|||