RELEASE V.4.0.0 : VERSION LONG SUPPORT (LTS)

--> Imports de ligne via fichier GTFS
--> Extrapolation possible des données
--> Générateur de fiches horaires
This commit is contained in:
mathis committed 2026-09-23 17:38:03 +02:00
1 parent 37ab831aac
commit 1c68740e41
25 files changed
+13251 -414

No files matched your search

@@ -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');
+66 -4
View File
@@ -16,6 +16,7 @@
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"jszip": "^3.10.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
@@ -12923,6 +12924,12 @@
"node": ">= 4" "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": { "node_modules/immer": {
"version": "9.0.21", "version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@@ -14986,6 +14993,54 @@
"node": ">=4.0" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -15072,6 +15127,15 @@
"node": ">= 0.8.0" "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": { "node_modules/lilconfig": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@@ -16462,8 +16526,7 @@
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)", "license": "(MIT AND Zlib)"
"peer": true
}, },
"node_modules/parallel-transform": { "node_modules/parallel-transform": {
"version": "1.2.0", "version": "1.2.0",
@@ -19915,8 +19978,7 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
+3 -1
View File
@@ -11,6 +11,7 @@
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"jszip": "^3.10.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
@@ -23,7 +24,8 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"updater": "node updater.js"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
+535
View File
@@ -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 };
}
+501
View File
@@ -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);
}
}
+128
View File
@@ -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' } };
}
}
+167
View File
@@ -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 };
}
}
+64 -50
View File
@@ -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 TABS = ['general', 'gares', 'jours', 'materiel', 'substitution'];
const TAB_LABELS = { general: 'Général', gares: 'Gares desservies', jours: 'Calendrier de circulation', materiel: 'Matériel roulant', substitution: 'Substitution' }; const TAB_LABELS = { general: 'Général', gares: 'Gares desservies', jours: 'Calendrier de circulation', materiel: 'Matériel roulant', substitution: 'Substitution' };
return ( return (
<div className="modal-overlay" onClick={closeModal}> <div className="modal-overlay" onClick={closeModal}>
<div <div
className="modal-content modal-xlarge" className="modal-content modal-xlarge horaire-modal-shell"
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
style={{ maxWidth: 900, display: 'flex', flexDirection: 'column', maxHeight: '92vh' }} style={{ maxWidth: 940, display: 'flex', flexDirection: 'column', maxHeight: '92vh' }}
> >
{/* ── Header ── */} <div className="modal-header horaire-modal-header">
<div className="modal-header"> <div>
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 700 }}> <div className="horaire-modal-kicker">Gestion du sillon</div>
{isDuplicating ? '📋 Dupliquer l\'horaire' : editingItem ? ' Modifier l\'horaire' : 'Nouvel horaire'} <h3>
{isDuplicating ? 'Dupliquer l\'horaire' : editingItem ? 'Modifier l\'horaire' : 'Nouvel horaire'}
</h3> </h3>
</div>
<button className="modal-close" onClick={closeModal}>×</button> <button className="modal-close" onClick={closeModal}>×</button>
</div> </div>
{/* ── Tabs ── */} <div className="horaire-modal-summary">
<div className="horaire-summary-pill">
<span className="horaire-summary-label">Départ</span>
<strong>{gareDepartNom || 'Gare inconnue'}</strong>
<span className="horaire-summary-time">{heureDepart || '--:--'}</span>
</div>
<div className="horaire-summary-arrow">→</div>
<div className="horaire-summary-pill">
<span className="horaire-summary-label">Arrivée</span>
<strong>{gareArriveeNom || 'Gare inconnue'}</strong>
<span className="horaire-summary-time">{heureArrivee || '--:--'}</span>
</div>
</div>
<div className="horaires-modal-tabs"> <div className="horaires-modal-tabs">
{TABS.map(t => ( {TABS.map(t => (
<button <button
@@ -420,7 +432,6 @@ const handleSave = async () => {
))} ))}
</div> </div>
{/* ── Body (scrollable) ── */}
<div className="horaires-modal-body-scroll"> <div className="horaires-modal-body-scroll">
{formError && ( {formError && (
<div className="horaires-form-error"> <div className="horaires-form-error">
@@ -428,18 +439,19 @@ const handleSave = async () => {
</div> </div>
)} )}
{/* ══ TAB GÉNÉRAL ══ */}
{activeTab === 'general' && ( {activeTab === 'general' && (
<div> <div className="horaire-modal-form">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}> <div className="horaire-modal-section-card">
<div> <div className="horaire-modal-section-header">Identification</div>
<label style={LBL}>Numéro de train <span style={{ color: '#dc2626' }}>*</span></label> <div className="horaire-modal-grid horaire-modal-grid-two">
<input style={INP} type="text" value={numeroTrain} onChange={e => setNumeroTrain(e.target.value)} placeholder="Ex: TER 12345" /> <div className="horaire-form-field">
<label className="horaire-label">Numéro de train <span>*</span></label>
<input className="horaire-input" type="text" value={numeroTrain} onChange={e => setNumeroTrain(e.target.value)} placeholder="Ex: TER 12345" />
</div> </div>
<div> <div className="horaire-form-field">
<label style={LBL}>Type de train</label> <label className="horaire-label">Type de train</label>
<select <select
style={{ width: '100%', padding: 10 }} className="horaire-select"
value={typeTrainId} value={typeTrainId}
onChange={e => { onChange={e => {
const id = e.target.value; const id = e.target.value;
@@ -454,25 +466,25 @@ const handleSave = async () => {
))} ))}
</select> </select>
{typeTrainNom && ( {typeTrainNom && (
<div style={{ fontSize: 12, color: '#0088ce', marginTop: 4 }}>✓ {typeTrainNom}</div> <div className="horaire-field-helper">✓ {typeTrainNom}</div>
)} )}
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}> <div className="horaire-modal-grid horaire-modal-grid-two">
<div> <div className="horaire-form-field">
<label style={LBL}>Ligne</label> <label className="horaire-label">Ligne</label>
<select style={INP} value={ligne} onChange={e => setLigne(e.target.value)}> <select className="horaire-select" value={ligne} onChange={e => setLigne(e.target.value)}>
<option value="">— Aucune ligne —</option> <option value="">— Aucune ligne —</option>
{lignes.map(l => ( {lignes.map(l => (
<option key={l.id} value={String(l.id)}>{l.nom}{l.code ? ` (${l.code})` : ''}</option> <option key={l.id} value={String(l.id)}>{l.nom}{l.code ? ` (${l.code})` : ''}</option>
))} ))}
</select> </select>
</div> </div>
<div> <div className="horaire-form-field">
<label style={LBL}>Service Annuel</label> <label className="horaire-label">Service Annuel</label>
<select <select
style={{ width: '100%', padding: 10 }} className="horaire-select"
value={serviceAnnuelId} value={serviceAnnuelId}
onChange={e => { onChange={e => {
const id = e.target.value; const id = e.target.value;
@@ -488,15 +500,16 @@ const handleSave = async () => {
</select> </select>
</div> </div>
</div> </div>
</div>
<p style={SEC}>Trajet</p> <div className="horaire-modal-section-card">
<div className="horaire-modal-section-header">Trajet</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 12, marginBottom: 14, alignItems: 'end' }}> <div className="horaire-modal-grid horaire-modal-grid-journey">
<div> <div className="horaire-form-field">
<label style={LBL}>Gare de départ <span style={{ color: '#dc2626' }}>*</span></label> <label className="horaire-label">Gare de départ <span>*</span></label>
<div className="horaires-gare-autocomplete"> <div className="horaires-gare-autocomplete">
<input <input
style={INP} className="horaire-input"
value={gareDepartNom || ''} value={gareDepartNom || ''}
onFocus={() => setGareSuggestionsOpenFor('depart')} onFocus={() => setGareSuggestionsOpenFor('depart')}
onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === 'depart' ? null : prev), 120)} onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === 'depart' ? null : prev), 120)}
@@ -522,18 +535,18 @@ const handleSave = async () => {
)} )}
</div> </div>
</div> </div>
<div> <div className="horaire-form-field">
<label style={LBL}>Heure départ <span style={{ color: '#dc2626' }}>*</span></label> <label className="horaire-label">Heure départ <span>*</span></label>
<input style={INP} type="time" value={heureDepart} onChange={e => setHeureDepart(e.target.value)} /> <input className="horaire-input" type="time" value={heureDepart} onChange={e => setHeureDepart(e.target.value)} />
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 12, marginBottom: 14, alignItems: 'end' }}> <div className="horaire-modal-grid horaire-modal-grid-journey">
<div> <div className="horaire-form-field">
<label style={LBL}>Gare d&apos;arrivée <span style={{ color: '#dc2626' }}>*</span></label> <label className="horaire-label">Gare d&apos;arrivée <span>*</span></label>
<div className="horaires-gare-autocomplete"> <div className="horaires-gare-autocomplete">
<input <input
style={INP} className="horaire-input"
value={gareArriveeNom || ''} value={gareArriveeNom || ''}
onFocus={() => setGareSuggestionsOpenFor('arrivee')} onFocus={() => setGareSuggestionsOpenFor('arrivee')}
onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === 'arrivee' ? null : prev), 120)} onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === 'arrivee' ? null : prev), 120)}
@@ -559,9 +572,10 @@ const handleSave = async () => {
)} )}
</div> </div>
</div> </div>
<div> <div className="horaire-form-field">
<label style={LBL}>Heure arrivée <span style={{ color: '#dc2626' }}>*</span></label> <label className="horaire-label">Heure arrivée <span>*</span></label>
<input style={INP} type="time" value={heureArrivee} onChange={e => setHeureArrivee(e.target.value)} /> <input className="horaire-input" type="time" value={heureArrivee} onChange={e => setHeureArrivee(e.target.value)} />
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -586,10 +600,10 @@ const handleSave = async () => {
</div> </div>
<div style={{ flex: 1, display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}> <div style={{ flex: 1, display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div style={{ flex: '2 1 200px' }}> <div style={{ flex: '2 1 200px' }}>
<label style={{ ...LBL, marginBottom: 4 }}>Gare #{idx + 1}</label> <label className="horaire-label" style={{ marginBottom: 4 }}>Gare #{idx + 1}</label>
<div className="horaires-gare-autocomplete"> <div className="horaires-gare-autocomplete">
<input <input
style={INP} className="horaire-input"
value={g.gare_nom || ''} value={g.gare_nom || ''}
onFocus={() => setGareSuggestionsOpenFor(`desservie-${idx}`)} onFocus={() => setGareSuggestionsOpenFor(`desservie-${idx}`)}
onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === `desservie-${idx}` ? null : prev), 120)} onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === `desservie-${idx}` ? null : prev), 120)}
@@ -621,12 +635,12 @@ const handleSave = async () => {
</div> </div>
</div> </div>
<div style={{ flex: '1 1 110px' }}> <div style={{ flex: '1 1 110px' }}>
<label style={{ ...LBL, marginBottom: 4 }}>Arrivée</label> <label className="horaire-label" style={{ marginBottom: 4 }}>Arrivée</label>
<input style={INP} type="time" value={g.heure_arrivee || ''} onChange={e => updateGareDesservieField(idx, 'heure_arrivee', e.target.value)} /> <input className="horaire-input" type="time" value={g.heure_arrivee || ''} onChange={e => updateGareDesservieField(idx, 'heure_arrivee', e.target.value)} />
</div> </div>
<div style={{ flex: '1 1 110px' }}> <div style={{ flex: '1 1 110px' }}>
<label style={{ ...LBL, marginBottom: 4 }}>Départ</label> <label className="horaire-label" style={{ marginBottom: 4 }}>Départ</label>
<input style={INP} type="time" value={g.heure_depart || ''} onChange={e => updateGareDesservieField(idx, 'heure_depart', e.target.value)} /> <input className="horaire-input" type="time" value={g.heure_depart || ''} onChange={e => updateGareDesservieField(idx, 'heure_depart', e.target.value)} />
</div> </div>
<button onClick={() => removeGareDesservie(idx)} style={{ background: '#dc2626', color: '#fff', border: 'none', borderRadius: 6, width: 36, height: 36, cursor: 'pointer', fontSize: 18, flexShrink: 0 }}>×</button> <button onClick={() => removeGareDesservie(idx)} style={{ background: '#dc2626', color: '#fff', border: 'none', borderRadius: 6, width: 36, height: 36, cursor: 'pointer', fontSize: 18, flexShrink: 0 }}>×</button>
</div> </div>
+4 -2
View File
@@ -44,7 +44,8 @@ const MENU_GROUPS = [
label: 'Diffusion', label: 'Diffusion',
items: [ items: [
{ label: "Gestion d'informations", href: '/informations', match: ['/informations'] }, { 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 === 'gares') permSection = 'gares';
else if (section === 'quais') permSection = 'quais'; else if (section === 'quais') permSection = 'quais';
else if (section === 'annonces-sonores') permSection = 'annonces_sonores'; else if (section === 'annonces-sonores') permSection = 'annonces_sonores';
else if (section === 'fiches-horaires') permSection = 'fiches_horaires';
else if (section === 'systeme-maj') permSection = 'systeme_maj'; else if (section === 'systeme-maj') permSection = 'systeme_maj';
return hasAccessTo(user, permSection); return hasAccessTo(user, permSection) || (permSection === 'fiches_horaires' && hasAccessTo(user, 'horaires'));
} }
return true; return true;
}); });
+7
View File
@@ -50,6 +50,13 @@ export default function NavPanel() {
</a> </a>
</wcs-nav-item>} </wcs-nav-item>}
{(hasAccessTo(currentUser, 'fiches_horaires') || hasAccessTo(currentUser, 'horaires')) && <wcs-nav-item className={isActive('/fiches-horaires') ? 'active' : ''}>
<a href="/fiches-horaires" aria-current={isActive('/fiches-horaires') ? 'page' : undefined}>
<wcs-mat-icon icon="description"></wcs-mat-icon>
<span>Fiches Horaires</span>
</a>
</wcs-nav-item>}
{hasAccessTo(currentUser, 'informations') && <wcs-nav-item className={isActive('/informations') ? 'active' : ''}> {hasAccessTo(currentUser, 'informations') && <wcs-nav-item className={isActive('/informations') ? 'active' : ''}>
<a href="/informations" aria-current={isActive('/informations') ? 'page' : undefined}> <a href="/informations" aria-current={isActive('/informations') ? 'page' : undefined}>
<wcs-mat-icon icon="info"></wcs-mat-icon> <wcs-mat-icon icon="info"></wcs-mat-icon>
@@ -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;
}
File diff suppressed because it is too large. Load diff
+26 -3
View File
@@ -2,9 +2,32 @@ import React, { useState, useMemo } from 'react';
import NavPanel from '../../components/nav-panel/NavPanel'; import NavPanel from '../../components/nav-panel/NavPanel';
import { getAllGares } from '../../api/gares'; import { getAllGares } from '../../api/gares';
// TODO: module api/gtfs non implémenté — fonctions stubées pour éviter le crash de build import { parseGTFSZip } from '../../api/gtfs';
const analyzeGTFSZip = async () => { throw new Error('analyzeGTFSZip non implémenté'); }; import { createGare } from '../../api/gares';
const importGares = async () => { throw new Error('importGares non implémenté'); };
const analyzeGTFSZip = async (file) => {
const parsed = await parseGTFSZip(file);
return { parsed };
};
const importGares = async (payload) => {
const stops = payload.stops || [];
let count = 0;
const errors = [];
for (const s of stops) {
const nom = s.stop_name || s.stop_id;
const res = await createGare({
nom,
type: 'ville',
quais: [],
transports: [],
informations: {}
});
if (res.error) errors.push(res.error);
else count++;
}
return { data: { count, total: stops.length }, error: errors.length ? errors[0] : null };
};
export default function ImportGaresGTFS() { export default function ImportGaresGTFS() {
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
+210
View File
@@ -829,6 +829,216 @@
animation: modalFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); animation: modalFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
} }
.horaire-modal-shell {
border: 1px solid rgba(13, 61, 103, 0.12);
box-shadow: 0 28px 60px -18px rgba(8, 46, 79, 0.38);
}
.horaire-modal-header {
background: linear-gradient(135deg, #061c2f 0%, #0d3d67 52%, #11507e 100%);
padding: 18px 22px 16px;
}
.horaire-modal-kicker {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #bae6fd;
margin-bottom: 6px;
font-weight: 700;
}
.horaire-modal-summary {
padding: 16px 22px 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.horaire-summary-pill {
display: flex;
flex-direction: column;
gap: 4px;
background: linear-gradient(180deg, #f8fbff 0%, #edf7ff 100%);
border: 1px solid #d8efff;
border-radius: 14px;
padding: 12px 14px;
}
.horaire-summary-label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #64748b;
}
.horaire-summary-pill strong {
font-size: 14px;
color: #0f172a;
}
.horaire-summary-time {
font-size: 12px;
color: #0088ce;
font-weight: 700;
}
.horaire-summary-arrow {
font-size: 28px;
color: #94a3b8;
text-align: center;
line-height: 1;
}
.horaire-modal-form {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 8px;
}
.horaire-modal-section-card {
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid var(--horaires-border);
border-radius: 16px;
padding: 16px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.65);
}
.horaire-modal-section-header {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #0d3d67;
margin-bottom: 14px;
}
.horaire-modal-grid {
display: grid;
gap: 14px;
}
.horaire-modal-grid-two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.horaire-modal-grid-journey {
grid-template-columns: minmax(0, 1fr) 180px;
align-items: end;
margin-bottom: 14px;
}
.horaire-form-field {
display: flex;
flex-direction: column;
}
.horaire-label {
display: block;
margin-bottom: 6px;
color: #334155;
font-size: 12.5px;
font-weight: 700;
}
.horaire-label span {
color: #dc2626;
}
.horaire-input,
.horaire-select {
width: 100%;
box-sizing: border-box;
border: 1px solid #d5ddea;
border-radius: 10px;
background: #ffffff;
color: #0f172a;
padding: 10px 12px;
font-size: 13.5px;
font-family: inherit;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.horaire-input:focus,
.horaire-select:focus {
border-color: #0088ce;
box-shadow: 0 0 0 3px rgba(0, 136, 206, 0.12);
}
.horaire-field-helper {
margin-top: 6px;
font-size: 11.5px;
color: #0369a1;
font-weight: 700;
}
.horaires-form-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 10px 12px;
margin-bottom: 14px;
font-size: 13px;
font-weight: 600;
}
.horaires-modal-body-scroll {
padding: 18px 22px 20px;
overflow-y: auto;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 16px);
}
.horaires-gare-autocomplete {
position: relative;
}
.horaires-gare-suggestions {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 6px);
background: #ffffff;
border: 1px solid #dbe5f0;
border-radius: 12px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
z-index: 20;
overflow: hidden;
}
.horaires-gare-suggestion-item {
width: 100%;
border: none;
background: transparent;
text-align: left;
padding: 10px 12px;
cursor: pointer;
font-size: 13px;
color: #0f172a;
}
.horaires-gare-suggestion-item:hover {
background: #f0f9ff;
}
@media (max-width: 768px) {
.horaire-modal-summary {
grid-template-columns: 1fr;
}
.horaire-summary-arrow {
display: none;
}
.horaire-modal-grid-two,
.horaire-modal-grid-journey {
grid-template-columns: 1fr;
}
}
@keyframes modalFadeIn { @keyframes modalFadeIn {
from { from {
opacity: 0; opacity: 0;
+1 -1
View File
@@ -89,7 +89,7 @@ export default function Horaires() {
const [garesTracees, setGaresTracees] = useState([]); const [garesTracees, setGaresTracees] = useState([]);
const [ligneFHUrl, setLigneFHUrl] = useState(''); const [ligneFHUrl, setLigneFHUrl] = useState('');
const [ligneFHFile, setLigneFHFile] = useState(null); const [ligneFHFile, setLigneFHFile] = useState(null);
const [ligneFHUploading, setLigneFHUploading] = useState(false); const [ligneFHUploading] = useState(false);
const [ligneFHError, setLigneFHError] = useState(''); const [ligneFHError, setLigneFHError] = useState('');
const [ligneModalTab, setLigneModalTab] = useState('infos'); const [ligneModalTab, setLigneModalTab] = useState('infos');
const [ligneRegion, setLigneRegion] = useState(''); const [ligneRegion, setLigneRegion] = useState('');
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2
View File
@@ -7,6 +7,7 @@ import Logout from '../logout/Logout';
import MaterielRoulant from '../materiel-roulant/MaterielRoulant'; import MaterielRoulant from '../materiel-roulant/MaterielRoulant';
import ImportsPage from '../horaires/ImportsPage'; import ImportsPage from '../horaires/ImportsPage';
import Horaires from '../horaires/Horaires'; import Horaires from '../horaires/Horaires';
import FichesHoraires from '../fiches-horaires/FichesHoraires';
import Parametres from "../parametres/Parametres"; import Parametres from "../parametres/Parametres";
import Perturbations from "../perturbations/Perturbations"; import Perturbations from "../perturbations/Perturbations";
import CreerPerturbation from '../perturbations/CreerPerturbation'; import CreerPerturbation from '../perturbations/CreerPerturbation';
@@ -46,6 +47,7 @@ export default function PanelRouter() {
if (path.startsWith('/materiel-roulant')) return denyIfNoAccess('materiel_roulant') || <MaterielRoulant />; if (path.startsWith('/materiel-roulant')) return denyIfNoAccess('materiel_roulant') || <MaterielRoulant />;
if (path.startsWith('/attribution-materiel-roulant')) return denyIfNoAccess('horaires') || <AttributionMaterielRoulant />; if (path.startsWith('/attribution-materiel-roulant')) return denyIfNoAccess('horaires') || <AttributionMaterielRoulant />;
if (path.startsWith('/horaires/imports')) return denyIfNoAccess('horaires', 'create') || <ImportsPage />; if (path.startsWith('/horaires/imports')) return denyIfNoAccess('horaires', 'create') || <ImportsPage />;
if (path.startsWith('/fiches-horaires')) return denyIfNoAccess('fiches_horaires') || <FichesHoraires />;
if (path.startsWith('/horaires')) return denyIfNoAccess('horaires') || <Horaires />; if (path.startsWith('/horaires')) return denyIfNoAccess('horaires') || <Horaires />;
if (path.startsWith('/perturbations/creer')) return denyIfNoAccess('perturbations', 'create') || <CreerPerturbation />; if (path.startsWith('/perturbations/creer')) return denyIfNoAccess('perturbations', 'create') || <CreerPerturbation />;
if (path.startsWith('/perturbations/modifier/')) return denyIfNoAccess('perturbations', 'update') || <ModifierPerturbation />; if (path.startsWith('/perturbations/modifier/')) return denyIfNoAccess('perturbations', 'update') || <ModifierPerturbation />;
+664 -136
View File
@@ -1,179 +1,707 @@
.maj-content { /* Ferrovia Panel - Styles Système de Mises à Jour */
.systeme-maj-page .maj-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px;
} }
.maj-card { /* Badges de connexion au service */
max-width: 800px; .maj-service-badge {
width: 100%; display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.82rem;
font-weight: 600;
border: 1px solid transparent;
} }
.maj-header { .maj-service-badge.online {
background: #ecfdf5;
color: #065f46;
border-color: #a7f3d0;
}
.maj-service-badge.offline {
background: #fef2f2;
color: #991b1b;
border-color: #fecaca;
}
.pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
animation: pulse-green 1.8s infinite;
}
.pulse-dot-red {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ef4444;
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
animation: pulse-red 1.8s infinite;
}
@keyframes pulse-green {
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
}
@keyframes pulse-red {
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
/* Alerte service hors-ligne */
.maj-offline-alert {
display: flex;
align-items: flex-start;
gap: 16px;
background: #fff1f2;
border: 1px solid #fecdd3;
border-left: 5px solid #e11d48;
border-radius: 8px;
padding: 16px 20px;
}
.maj-offline-icon {
background: #ffe4e6;
color: #e11d48;
border-radius: 50%;
padding: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px;
padding-bottom: 24px;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 24px;
}
.maj-header wcs-mat-icon {
font-size: 48px;
color: #0088ce;
}
.maj-title h3 {
margin: 0 0 4px 0;
font-size: 18px;
color: #374151;
}
.version-text {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #10b981;
}
.maj-body {
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
} }
.maj-actions { .maj-offline-commands {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.maj-code-box {
display: inline-flex;
align-items: center;
background: #1e293b;
border-radius: 6px;
padding: 4px 6px 4px 12px;
gap: 8px;
}
.maj-code-box code {
color: #38bdf8;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.84rem;
}
.maj-code-box button {
background: #334155;
border: none;
color: #f1f5f9;
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.maj-code-box button:hover {
background: #475569;
}
/* Layout Grille */
.maj-grid-layout {
display: grid;
grid-template-columns: 1fr 1.35fr;
gap: 20px;
}
@media (max-width: 960px) {
.maj-grid-layout {
grid-template-columns: 1fr;
}
}
/* Cartes */
.maj-card {
padding: 24px;
border-radius: 12px;
background: #ffffff;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.maj-card-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid #f1f5f9;
}
.maj-card-title h3 {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
color: #1e293b;
}
/* Métadonnées Système */
.maj-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.maj-meta-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px;
}
.maj-meta-item.full-width {
grid-column: 1 / -1;
}
.maj-meta-label {
font-size: 0.8rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.maj-meta-value {
font-size: 0.92rem;
font-weight: 500;
color: #1e293b;
}
.maj-meta-value.italic {
font-style: italic;
color: #475569;
}
.maj-meta-value.code-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8rem;
background: #f8fafc;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #e2e8f0;
word-break: break-all;
}
.version-tag {
display: inline-block;
background: #dbeafe;
color: #1d4ed8;
font-weight: 700;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.88rem;
width: fit-content;
}
.branch-tag {
display: inline-flex;
align-items: center;
gap: 4px;
background: #f1f5f9;
color: #334155;
font-weight: 600;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.88rem;
width: fit-content;
}
.commit-tag {
display: inline-block;
background: #fef3c7;
color: #b45309;
font-family: monospace;
font-weight: 700;
padding: 2px 8px;
border-radius: 6px;
font-size: 0.88rem;
width: fit-content;
}
/* Alertes & États */
.maj-state-box {
display: flex;
flex-direction: column;
}
.maj-state-box.center {
align-items: center; align-items: center;
text-align: center; text-align: center;
gap: 16px; padding: 40px 20px;
} }
.maj-actions p { .maj-alert-success {
color: #6b7280;
margin: 0;
max-width: 500px;
}
.alert-new-version {
display: flex;
align-items: flex-start;
gap: 12px;
background: #fef3c7;
border-left: 4px solid #d97706;
padding: 16px;
border-radius: 4px;
text-align: left;
max-width: 600px;
width: 100%;
}
.alert-new-version strong {
display: block;
color: #92400e;
margin-bottom: 4px;
}
.alert-new-version p {
color: #b45309;
font-size: 14px;
margin: 0;
}
.progress-container {
width: 100%;
max-width: 500px;
margin: 16px 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-left: 5px solid #16a34a;
border-radius: 8px;
padding: 16px;
color: #166534;
} }
.progress-container wcs-progress-bar { .maj-alert-success.large {
flex: 1;
}
.progress-text {
font-weight: bold;
color: #0088ce;
min-width: 40px;
}
.progress-details {
font-size: 13px !important;
font-style: italic;
}
.maj-actions.success {
color: #16a34a;
}
.maj-actions.success p {
color: #15803d;
font-weight: 500;
}
/* Dark mode adjustments */
body.dark-mode .maj-content,
[data-theme="dark"] .maj-content {
background: transparent;
}
body.dark-mode .maj-card,
[data-theme="dark"] .maj-card {
background: #151f30;
border: 1px solid #243247;
color: #f8fafc;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
border-radius: 12px;
padding: 20px; padding: 20px;
} }
body.dark-mode .maj-header, .maj-alert-warning {
[data-theme="dark"] .maj-header { display: flex;
border-bottom-color: #243247; align-items: center;
gap: 16px;
background: #fffbeb;
border: 1px solid #fde68a;
border-left: 5px solid #d97706;
border-radius: 8px;
padding: 16px;
color: #92400e;
} }
body.dark-mode .maj-title h3, .maj-alert-error {
[data-theme="dark"] .maj-title h3 { display: flex;
color: #f8fafc; align-items: flex-start;
gap: 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 5px solid #dc2626;
border-radius: 8px;
padding: 16px;
} }
body.dark-mode .maj-actions p, .maj-inline-error {
[data-theme="dark"] .maj-actions p { display: flex;
align-items: center;
gap: 8px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
font-size: 0.85rem;
padding: 8px 12px;
border-radius: 6px;
margin-bottom: 14px;
}
/* Changelog List */
.maj-changelog-container {
margin-top: 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 16px;
background: #f8fafc;
}
.maj-changelog-list {
max-height: 180px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.maj-commit-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding-bottom: 6px;
border-bottom: 1px dashed #e2e8f0;
}
.maj-commit-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.maj-commit-hash {
background: #e2e8f0;
color: #475569;
font-family: monospace;
font-size: 0.78rem;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
}
.maj-commit-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.maj-commit-msg {
font-size: 0.88rem;
font-weight: 600;
color: #1e293b;
}
.maj-commit-sub {
font-size: 0.78rem;
color: #64748b;
}
/* Options */
.maj-options-box {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 14px;
padding: 12px 14px;
background: #f1f5f9;
border-radius: 8px;
}
.maj-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.86rem;
color: #334155;
cursor: pointer;
}
.maj-checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #0088ce;
cursor: pointer;
}
/* Stepper Visuel 4 étapes */
.maj-stepper {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
padding: 0 8px;
}
.maj-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 2;
}
.step-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: #e2e8f0;
color: #64748b;
font-weight: 700;
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.step-label {
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
}
.maj-step.active .step-circle {
background: #0088ce;
color: #ffffff;
box-shadow: 0 0 0 4px rgba(0, 136, 206, 0.2);
transform: scale(1.1);
}
.maj-step.active .step-label {
color: #0088ce;
font-weight: 700;
}
.maj-step.completed .step-circle {
background: #10b981;
color: #ffffff;
}
.maj-step.completed .step-label {
color: #10b981;
}
.step-line {
flex: 1;
height: 3px;
background: #e2e8f0;
margin: 0 6px 20px;
transition: background 0.3s;
}
.step-line.active {
background: #10b981;
}
/* Progress Bar */
.maj-progress-wrapper {
margin-bottom: 16px;
}
.maj-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.step-text {
font-size: 0.88rem;
color: #475569;
}
.percent-text {
font-size: 0.95rem;
font-weight: 800;
color: #0088ce;
}
.maj-progress-bar-bg {
width: 100%;
height: 10px;
background: #e2e8f0;
border-radius: 5px;
overflow: hidden;
}
.maj-progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #0088ce 0%, #10b981 100%);
border-radius: 5px;
transition: width 0.4s ease;
}
/* Terminal Viewer */
.maj-terminal-wrapper {
background: #0f172a;
border-radius: 8px;
border: 1px solid #1e293b;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.maj-terminal-header {
background: #1e293b;
padding: 8px 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.terminal-dots {
display: flex;
gap: 6px;
}
.terminal-dots .dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.terminal-dots .dot.red { background: #ef4444; }
.terminal-dots .dot.yellow { background: #f59e0b; }
.terminal-dots .dot.green { background: #10b981; }
.terminal-title {
color: #94a3b8;
font-size: 0.78rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.btn-copy-logs {
background: #334155;
border: none;
color: #cbd5e1;
font-size: 0.72rem;
padding: 3px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.btn-copy-logs:hover {
background: #475569;
color: #ffffff;
}
.maj-terminal-body {
height: 220px;
overflow-y: auto;
padding: 12px 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8rem;
line-height: 1.5;
display: flex;
flex-direction: column;
gap: 3px;
}
.log-line {
display: flex;
gap: 8px;
word-break: break-all;
}
.log-time {
color: #64748b;
user-select: none;
}
.log-line.cmd .log-text {
color: #38bdf8;
font-weight: 700;
}
.log-line.stdout .log-text {
color: #e2e8f0;
}
.log-line.stderr .log-text {
color: #fbbf24;
}
.log-line.error .log-text {
color: #f87171;
font-weight: 700;
}
.log-line.success .log-text {
color: #4ade80;
font-weight: 700;
}
.log-line.info .log-text {
color: #94a3b8; color: #94a3b8;
} }
body.dark-mode .alert-new-version, /* Floating Toast */
[data-theme="dark"] .alert-new-version { .maj-floating-toast {
background: rgba(245, 158, 11, 0.12); position: fixed;
border-color: #f59e0b; bottom: 24px;
right: 24px;
background: #1e293b;
color: #ffffff;
padding: 10px 18px;
border-radius: 8px;
font-size: 0.88rem;
font-weight: 600;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25);
z-index: 10000;
animation: fadeIn 0.2s ease-out;
} }
body.dark-mode .alert-new-version strong, @keyframes fadeIn {
[data-theme="dark"] .alert-new-version strong { from { opacity: 0; transform: translateY(10px); }
color: #fde68a; to { opacity: 1; transform: translateY(0); }
} }
body.dark-mode .alert-new-version p, /* Dark Mode Theme Support */
[data-theme="dark"] .alert-new-version p { body.dark-mode .maj-card,
color: #fcd34d; [data-theme="dark"] .maj-card {
background: #151f30;
border-color: #243247;
color: #f8fafc;
} }
body.dark-mode .progress-text, body.dark-mode .maj-card-title,
[data-theme="dark"] .progress-text { [data-theme="dark"] .maj-card-title {
border-bottom-color: #243247;
}
body.dark-mode .maj-card-title h3,
[data-theme="dark"] .maj-card-title h3 {
color: #f8fafc;
}
body.dark-mode .maj-meta-value,
[data-theme="dark"] .maj-meta-value {
color: #f1f5f9;
}
body.dark-mode .maj-meta-value.italic,
[data-theme="dark"] .maj-meta-value.italic {
color: #94a3b8;
}
body.dark-mode .maj-meta-value.code-text,
[data-theme="dark"] .maj-meta-value.code-text {
background: #0f172a;
border-color: #334155;
color: #38bdf8; color: #38bdf8;
} }
@media (max-width: 600px) { body.dark-mode .branch-tag,
.maj-header { [data-theme="dark"] .branch-tag {
flex-direction: column; background: #1e293b;
align-items: flex-start; color: #e2e8f0;
gap: 10px;
}
.alert-new-version {
flex-direction: column;
}
} }
body.dark-mode .maj-changelog-container,
[data-theme="dark"] .maj-changelog-container {
background: #0f172a;
border-color: #243247;
}
body.dark-mode .maj-commit-msg,
[data-theme="dark"] .maj-commit-msg {
color: #f8fafc;
}
body.dark-mode .maj-commit-hash,
[data-theme="dark"] .maj-commit-hash {
background: #1e293b;
color: #94a3b8;
}
body.dark-mode .maj-options-box,
[data-theme="dark"] .maj-options-box {
background: #0f172a;
border: 1px solid #243247;
}
body.dark-mode .maj-checkbox-label,
[data-theme="dark"] .maj-checkbox-label {
color: #cbd5e1;
}
body.dark-mode .maj-progress-bar-bg,
[data-theme="dark"] .maj-progress-bar-bg {
background: #1e293b;
}
body.dark-mode .step-circle,
[data-theme="dark"] .step-circle {
background: #1e293b;
color: #94a3b8;
}
body.dark-mode .step-line,
[data-theme="dark"] .step-line {
background: #1e293b;
}
+618 -105
View File
@@ -1,153 +1,666 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
checkUpdaterHealth,
fetchSystemInfo,
fetchCheckUpdates,
startUpdatePipeline,
pollUpdateStatus,
resetUpdateState
} from '../../api/updater';
import './SystemeMaj.css'; import './SystemeMaj.css';
export default function SystemeMaj() { export default function SystemeMaj() {
const [status, setStatus] = useState('idle'); // idle, checking, available, updating, success // Service Health & Connection
const [serviceHealth, setServiceHealth] = useState({
isOnline: false,
latency: null,
data: null,
error: null,
checkedAt: null
});
const [checkingHealth, setCheckingHealth] = useState(false);
// System Metadata
const [systemInfo, setSystemInfo] = useState({
version: '0.1.0',
branch: 'main',
commitHash: '',
commitMessage: '',
commitDate: '',
commitAuthor: '',
remoteUrl: ''
});
// Update Check State
const [status, setStatus] = useState('idle'); // 'idle' | 'checking' | 'available' | 'up_to_date' | 'updating' | 'success' | 'error'
const [updateCheckResult, setUpdateCheckResult] = useState(null);
const [checkError, setCheckError] = useState(null);
// Update Execution State
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [version, setVersion] = useState('v0.1.0 (Bêta)');
const [newVersion, setNewVersion] = useState(null);
const [logs, setLogs] = useState([]);
const [stepName, setStepName] = useState(''); const [stepName, setStepName] = useState('');
const [logs, setLogs] = useState([]);
const [errorDetails, setErrorDetails] = useState(null);
const [countdown, setCountdown] = useState(null);
const checkForUpdates = () => { // Options
setStatus('checking'); const [optionSkipBuild, setOptionSkipBuild] = useState(false);
setTimeout(() => { const [optionRestartPm2, setOptionRestartPm2] = useState(true);
// Pour une vérification réelle, on pourrait interroger un endpoint /api/check-update const [optionStash, setOptionStash] = useState(true);
setNewVersion('Dernière version (Git)');
setStatus('available'); // Toast / Copy notification
}, 1500); const [copiedToast, setCopiedToast] = useState(false);
const terminalLogsRef = useRef(null);
const pollingIntervalRef = useRef(null);
// Défilement automatique du terminal
useEffect(() => {
if (terminalLogsRef.current) {
terminalLogsRef.current.scrollTop = terminalLogsRef.current.scrollHeight;
}
}, [logs]);
// Nettoyage du polling à la destruction du composant
useEffect(() => {
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
}; };
}, []);
const applyUpdate = async () => { // 1. Diagnostic de santé du service updater
setStatus('updating'); const verifyServiceHealth = useCallback(async () => {
setProgress(0); setCheckingHealth(true);
try { try {
// Démarre la mise à jour sur le backend const health = await checkUpdaterHealth();
await fetch('http://localhost:3001/api/update', { method: 'POST' }); setServiceHealth({
...health,
checkedAt: new Date().toLocaleTimeString('fr-FR')
});
// Polling de l'état if (health.isOnline) {
const interval = setInterval(async () => { // Charger les informations système
try { const info = await fetchSystemInfo();
const res = await fetch('http://localhost:3001/api/update/status'); setSystemInfo(prev => ({ ...prev, ...info }));
const data = await res.json();
setProgress(data.progress);
setStepName(data.step);
setLogs(data.logs || []);
if (data.status === 'success') {
clearInterval(interval);
setStatus('success');
setVersion('À jour');
setNewVersion(null);
// Reset le backend après succès
setTimeout(() => {
fetch('http://localhost:3001/api/update/reset', { method: 'POST' });
window.location.reload();
}, 3000);
} else if (data.status === 'error') {
clearInterval(interval);
setStatus('error');
} }
} catch (err) { } catch (err) {
console.error("Erreur de connexion avec l'updater:", err); setServiceHealth({
isOnline: false,
latency: null,
data: null,
error: err.message,
checkedAt: new Date().toLocaleTimeString('fr-FR')
});
} finally {
setCheckingHealth(false);
}
}, []);
useEffect(() => {
verifyServiceHealth();
}, [verifyServiceHealth]);
// 2. Vérification des mises à jour distantes (Git fetch & diff)
const handleCheckForUpdates = async () => {
setStatus('checking');
setCheckError(null);
try {
// S'assurer que le service est joignable
const health = await checkUpdaterHealth();
setServiceHealth(prev => ({ ...prev, ...health }));
if (!health.isOnline) {
throw new Error("Le service updater n'est pas joignable sur le port 4001. Veuillez le démarrer via `node updater.js`.");
}
const res = await fetchCheckUpdates();
setUpdateCheckResult(res);
if (res.localInfo) {
setSystemInfo(prev => ({ ...prev, ...res.localInfo }));
}
if (res.hasUpdate && res.commitsBehind > 0) {
setStatus('available');
} else {
setStatus('up_to_date');
}
} catch (err) {
console.error("Erreur vérification mise à jour:", err);
setCheckError(err.message || "Erreur lors de la vérification.");
setStatus('idle');
}
};
// 3. Déclenchement de la mise à jour
const handleApplyUpdate = async () => {
setStatus('updating');
setProgress(5);
setStepName('Initialisation...');
setLogs([]);
setErrorDetails(null);
try {
await startUpdatePipeline({
skipBuild: optionSkipBuild,
restartPm2: optionRestartPm2,
stash: optionStash
});
// Démarrer le polling du statut
if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = setInterval(async () => {
try {
const data = await pollUpdateStatus();
setProgress(data.progress || 0);
setStepName(data.step || '');
if (Array.isArray(data.logs)) {
setLogs(data.logs);
}
if (data.status === 'success') {
clearInterval(pollingIntervalRef.current);
setStatus('success');
setProgress(100);
// Recharger les infos système
fetchSystemInfo().then(info => setSystemInfo(prev => ({ ...prev, ...info })));
// Déclencher le compte à rebours de rechargement
let count = 5;
setCountdown(count);
const countTimer = setInterval(() => {
count -= 1;
setCountdown(count);
if (count <= 0) {
clearInterval(countTimer);
window.location.reload();
} }
}, 1000); }, 1000);
} catch (error) {
console.error("Impossible de lancer la mise à jour:", error); } else if (data.status === 'error') {
clearInterval(pollingIntervalRef.current);
setStatus('error'); setStatus('error');
setErrorDetails(data.error || "Une erreur est survenue pendant la mise à jour.");
} }
} catch (pollErr) {
console.warn("Erreur polling update:", pollErr);
}
}, 1200);
} catch (err) {
console.error("Impossible d'initialiser la mise à jour:", err);
setStatus('error');
setErrorDetails(err.message || "Échec de connexion avec le service de mise à jour.");
}
};
// 4. Réinitialisation
const handleReset = async () => {
if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
try {
await resetUpdateState();
} catch (e) {}
setStatus('idle');
setProgress(0);
setStepName('');
setLogs([]);
setErrorDetails(null);
setCountdown(null);
verifyServiceHealth();
};
// 5. Copie des logs dans le presse-papier
const handleCopyLogs = () => {
const text = logs.map(l => `[${l.time}] [${l.type}] ${l.text}`).join('\n');
navigator.clipboard.writeText(text || "Aucun log");
setCopiedToast(true);
setTimeout(() => setCopiedToast(false), 2500);
};
// Déterminer l'étape active dans le stepper
const getActiveStepIndex = () => {
if (progress < 15) return 0;
if (progress < 40) return 1;
if (progress < 65) return 2;
if (progress < 90) return 3;
return 4;
}; };
return ( return (
<div className="panel-layout"> <div className="sncf-groupe panel-layout systeme-maj-page">
<div className="nav-panel"> <div className="nav-panel">
<wcs-nav> <wcs-nav>
<wcs-nav-item> <wcs-nav-item>
<wcs-mat-icon icon="update"></wcs-mat-icon> <wcs-mat-icon icon="system_update_alt"></wcs-mat-icon>
<span>Mises à jour</span> <span>Mises à jour</span>
</wcs-nav-item> </wcs-nav-item>
</wcs-nav> </wcs-nav>
</div> </div>
<div className="panel-content maj-content"> <div className="panel-content maj-content">
<div className="page-header"> {/* En-tête de page */}
<h2>Système de mises à jour</h2> <div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<span className="station-type-badge ville" style={{ background: '#0088ce', color: '#fff' }}>Espace Administrateur</span>
</div>
<div className="widget-card maj-card">
<div className="maj-header">
<wcs-mat-icon icon="system_update_alt" size="l"></wcs-mat-icon>
<div className="maj-title">
<h3>Version Actuelle</h3>
<p className="version-text">{version}</p>
</div>
</div>
<div className="maj-body">
{status === 'idle' && (
<div className="maj-actions">
<p>Votre système semble à jour. Vous pouvez forcer une vérification pour synchroniser avec le dépôt.</p>
<wcs-button onClick={checkForUpdates} className="wcs-primary">Vérifier les mises à jour</wcs-button>
</div>
)}
{status === 'checking' && (
<div className="maj-actions">
<wcs-spinner></wcs-spinner>
<p>Recherche de mises à jour sur le serveur Git distant...</p>
</div>
)}
{status === 'available' && (
<div className="maj-actions">
<div className="alert-new-version">
<wcs-mat-icon icon="new_releases" style={{ color: '#d97706' }}></wcs-mat-icon>
<div> <div>
<strong>Mise à jour prête : {newVersion}</strong> <h2 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: 10 }}>
<p>Cette action va exécuter `git pull`, puis recompiler l'application. Le panel sera temporairement indisponible.</p> <wcs-mat-icon icon="system_update_alt" style={{ color: '#0088ce' }}></wcs-mat-icon>
Gestionnaire de Mises à Jour
</h2>
<p style={{ margin: '4px 0 0', color: '#64748b', fontSize: '0.9rem' }}>
Maintenance automatisée, synchronisation Git, recompilation et gestion de processus
</p>
</div>
{/* Badge statut du service */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{serviceHealth.isOnline ? (
<div className="maj-service-badge online" title={`Connecté à ${serviceHealth.url}`}>
<span className="pulse-dot"></span>
<span>Daemon Actif ({serviceHealth.latency} ms • Port {serviceHealth.data?.port || 4001})</span>
</div>
) : (
<div className="maj-service-badge offline" title={serviceHealth.error || "Non connecté"}>
<span className="pulse-dot-red"></span>
<span>Daemon Inaccessible</span>
</div>
)}
<wcs-button
mode="stroked"
size="s"
onClick={verifyServiceHealth}
disabled={checkingHealth}
>
<wcs-mat-icon icon="refresh" size="s"></wcs-mat-icon>
{checkingHealth ? 'Vérification...' : 'Diagnostic'}
</wcs-button>
</div>
</div>
{/* Alerte si le service updater est hors ligne */}
{!serviceHealth.isOnline && (
<div className="maj-offline-alert">
<div className="maj-offline-icon">
<wcs-mat-icon icon="dns" size="m"></wcs-mat-icon>
</div>
<div className="maj-offline-body">
<h4 style={{ margin: '0 0 6px 0', color: '#991b1b' }}>
Le service de mise à jour (Daemon Updater) n'est pas démarré
</h4>
<p style={{ margin: '0 0 10px 0', fontSize: '0.88rem', color: '#7f1d1d' }}>
Pour permettre à l'application d'exécuter les commandes système (Git pull, NPM install, Build, PM2),
le service autonome <code>updater.js</code> doit tourner en arrière-plan sur le serveur.
</p>
<div className="maj-offline-commands">
<div className="maj-code-box">
<code>node updater.js</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText('node updater.js');
setCopiedToast(true);
setTimeout(() => setCopiedToast(false), 2000);
}}
>
Copier
</button>
</div>
<span style={{ fontSize: '0.85rem', color: '#64748b' }}>ou en production (PM2) :</span>
<div className="maj-code-box">
<code>pm2 start updater.js --name "ferrovia-updater"</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText('pm2 start updater.js --name "ferrovia-updater"');
setCopiedToast(true);
setTimeout(() => setCopiedToast(false), 2000);
}}
>
Copier
</button>
</div>
</div> </div>
</div> </div>
<wcs-button onClick={applyUpdate} className="wcs-primary">Lancer la mise à jour (PM2)</wcs-button>
</div> </div>
)} )}
{status === 'updating' && ( {/* Toast Copié */}
<div className="maj-actions" style={{ maxWidth: '600px', width: '100%' }}> {copiedToast && (
<p>Mise à jour en cours sur le serveur...</p> <div className="maj-floating-toast">
<div className="progress-container"> ✓ Copié dans le presse-papier !
<wcs-progress-bar value={progress}></wcs-progress-bar>
<span className="progress-text">{progress}%</span>
</div> </div>
<p className="progress-details" style={{ fontWeight: 'bold', margin: '10px 0' }}> )}
Étape : {stepName || "Initialisation..."}
{/* Grille principale */}
<div className="maj-grid-layout">
{/* Carte 1 : Informations système actuelles */}
<div className="widget-card maj-card">
<div className="maj-card-title">
<wcs-mat-icon icon="info" style={{ color: '#0088ce' }}></wcs-mat-icon>
<h3>Environnement Local</h3>
</div>
<div className="maj-meta-grid">
<div className="maj-meta-item">
<span className="maj-meta-label">Version installée :</span>
<span className="maj-meta-value version-tag">v{systemInfo.version || '0.1.0'}</span>
</div>
<div className="maj-meta-item">
<span className="maj-meta-label">Branche active :</span>
<span className="maj-meta-value branch-tag">
<wcs-mat-icon icon="merge_type" size="s"></wcs-mat-icon>
{systemInfo.branch || 'main'}
</span>
</div>
<div className="maj-meta-item">
<span className="maj-meta-label">Dernier Commit :</span>
<span className="maj-meta-value commit-tag" title={systemInfo.commitFullHash || ''}>
#{systemInfo.commitHash || 'latest'}
</span>
</div>
<div className="maj-meta-item">
<span className="maj-meta-label">Auteur du commit :</span>
<span className="maj-meta-value">{systemInfo.commitAuthor || 'Ferrovia Team'}</span>
</div>
<div className="maj-meta-item full-width">
<span className="maj-meta-label">Message du commit :</span>
<span className="maj-meta-value italic">{systemInfo.commitMessage || 'Version initiale'}</span>
</div>
{systemInfo.remoteUrl && (
<div className="maj-meta-item full-width">
<span className="maj-meta-label">Dépôt Git distant :</span>
<span className="maj-meta-value code-text">{systemInfo.remoteUrl}</span>
</div>
)}
</div>
</div>
{/* Carte 2 : Centre d'Action & Statut de Mise à Jour */}
<div className="widget-card maj-card">
<div className="maj-card-title">
<wcs-mat-icon icon="cloud_sync" style={{ color: '#0088ce' }}></wcs-mat-icon>
<h3>Synchronisation &amp; Déploiement</h3>
</div>
{/* État 1 : Idle (En attente) */}
{status === 'idle' && (
<div className="maj-state-box">
<p style={{ margin: '0 0 16px', color: '#475569' }}>
Vérifiez la présence de nouvelles versions ou de correctifs publiés sur le dépôt distant.
</p> </p>
<div className="terminal-logs" style={{ background: '#1e1e1e', color: '#e0e0e0', padding: '10px', borderRadius: '6px', height: '150px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '12px', textAlign: 'left' }}> {checkError && (
{logs.length === 0 ? "En attente des logs..." : logs.map((log, i) => ( <div className="maj-inline-error">
<div key={i}>{log}</div> <wcs-mat-icon icon="warning" style={{ color: '#dc2626' }}></wcs-mat-icon>
<span>{checkError}</span>
</div>
)}
<wcs-button
onClick={handleCheckForUpdates}
className="btn-gradient"
disabled={!serviceHealth.isOnline}
>
<wcs-mat-icon icon="search" size="s"></wcs-mat-icon>
Vérifier les mises à jour distantes
</wcs-button>
</div>
)}
{/* État 2 : Checking (Recherche en cours) */}
{status === 'checking' && (
<div className="maj-state-box center">
<wcs-spinner mode="border"></wcs-spinner>
<p style={{ marginTop: 12, fontWeight: 500, color: '#0284c7' }}>
Interrogation du dépôt Git distant (fetch &amp; diff)...
</p>
</div>
)}
{/* État 3 : Up to Date (Système à jour) */}
{status === 'up_to_date' && (
<div className="maj-state-box">
<div className="maj-alert-success">
<wcs-mat-icon icon="check_circle" style={{ color: '#16a34a', fontSize: '32px' }}></wcs-mat-icon>
<div>
<strong>Votre système est parfaitement à jour !</strong>
<p style={{ margin: '2px 0 0', fontSize: '0.88rem' }}>
Aucun nouveau commit n'a été détecté sur la branche <code>{systemInfo.branch || 'main'}</code>.
</p>
</div>
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 10 }}>
<wcs-button mode="stroked" onClick={handleCheckForUpdates}>
<wcs-mat-icon icon="refresh" size="s"></wcs-mat-icon>
Re-vérifier
</wcs-button>
<wcs-button mode="clear" onClick={handleApplyUpdate} title="Forcer la réinstallation et recompilation">
Forcer une recompilation
</wcs-button>
</div>
</div>
)}
{/* État 4 : Available (Mise à jour disponible) */}
{status === 'available' && updateCheckResult && (
<div className="maj-state-box">
<div className="maj-alert-warning">
<wcs-mat-icon icon="new_releases" style={{ color: '#d97706', fontSize: '36px' }}></wcs-mat-icon>
<div>
<strong>
{updateCheckResult.commitsBehind} nouvelle(s) mise(s) à jour disponible(s) !
</strong>
<p style={{ margin: '3px 0 0', fontSize: '0.88rem' }}>
Des modifications ont été publiées sur la branche <code>{updateCheckResult.branch}</code>.
</p>
</div>
</div>
{/* Liste des commits entrants */}
{Array.isArray(updateCheckResult.incomingCommits) && updateCheckResult.incomingCommits.length > 0 && (
<div className="maj-changelog-container">
<h5 style={{ margin: '0 0 8px', color: '#1e293b', fontSize: '0.9rem' }}>
Journal des modifications (Changelog) :
</h5>
<div className="maj-changelog-list">
{updateCheckResult.incomingCommits.map((c, idx) => (
<div key={idx} className="maj-commit-item">
<span className="maj-commit-hash">#{c.hash}</span>
<div className="maj-commit-details">
<span className="maj-commit-msg">{c.message}</span>
<span className="maj-commit-sub">
{c.author} • {c.relativeDate || c.date}
</span>
</div>
</div>
))} ))}
</div> </div>
</div> </div>
)} )}
{status === 'error' && ( {/* Options d'exécution */}
<div className="maj-actions error"> <div className="maj-options-box">
<wcs-mat-icon icon="error" style={{ color: '#dc2626', fontSize: '48px' }}></wcs-mat-icon> <label className="maj-checkbox-label">
<p style={{ color: '#dc2626', fontWeight: 'bold' }}>Une erreur critique est survenue durant la mise à jour.</p> <input
<p>Consultez les logs du serveur (PM2) ou vérifiez les permissions Git.</p> type="checkbox"
<wcs-button onClick={() => setStatus('idle')} className="wcs-secondary">Retour</wcs-button> checked={optionRestartPm2}
onChange={(e) => setOptionRestartPm2(e.target.checked)}
/>
<span>Recharger automatiquement les processus PM2 à la fin</span>
</label>
<label className="maj-checkbox-label">
<input
type="checkbox"
checked={!optionSkipBuild}
onChange={(e) => setOptionSkipBuild(!e.target.checked)}
/>
<span>Recompiler les assets de production (npm run build)</span>
</label>
<label className="maj-checkbox-label">
<input
type="checkbox"
checked={optionStash}
onChange={(e) => setOptionStash(e.target.checked)}
/>
<span>Mettre en réserve les fichiers locaux non commités (git stash)</span>
</label>
</div>
{/* Bouton de déclenchement */}
<div style={{ marginTop: 18, display: 'flex', gap: 12 }}>
<wcs-button className="btn-gradient" onClick={handleApplyUpdate}>
<wcs-mat-icon icon="download" size="s"></wcs-mat-icon>
Lancer la mise à jour maintenant
</wcs-button>
<wcs-button mode="stroked" onClick={() => setStatus('idle')}>
Annuler
</wcs-button>
</div>
</div> </div>
)} )}
{/* État 5 : Updating (Exécution du pipeline) */}
{status === 'updating' && (
<div className="maj-state-box">
{/* Stepper visuel 4 étapes */}
<div className="maj-stepper">
<div className={`maj-step ${getActiveStepIndex() >= 1 ? 'completed' : 'active'}`}>
<div className="step-circle">1</div>
<div className="step-label">Git Pull</div>
</div>
<div className={`step-line ${getActiveStepIndex() >= 2 ? 'active' : ''}`}></div>
<div className={`maj-step ${getActiveStepIndex() >= 2 ? 'completed' : getActiveStepIndex() === 1 ? 'active' : ''}`}>
<div className="step-circle">2</div>
<div className="step-label">NPM Install</div>
</div>
<div className={`step-line ${getActiveStepIndex() >= 3 ? 'active' : ''}`}></div>
<div className={`maj-step ${getActiveStepIndex() >= 3 ? 'completed' : getActiveStepIndex() === 2 ? 'active' : ''}`}>
<div className="step-circle">3</div>
<div className="step-label">Build React</div>
</div>
<div className={`step-line ${getActiveStepIndex() >= 4 ? 'active' : ''}`}></div>
<div className={`maj-step ${getActiveStepIndex() >= 4 ? 'completed' : getActiveStepIndex() === 3 ? 'active' : ''}`}>
<div className="step-circle">4</div>
<div className="step-label">PM2 Reload</div>
</div>
</div>
{/* Barre de progression */}
<div className="maj-progress-wrapper">
<div className="maj-progress-header">
<span className="step-text">Étape en cours : <strong>{stepName || 'Traitement...'}</strong></span>
<span className="percent-text">{progress}%</span>
</div>
<div className="maj-progress-bar-bg">
<div className="maj-progress-bar-fill" style={{ width: `${progress}%` }}></div>
</div>
</div>
{/* Terminal interactif */}
<div className="maj-terminal-wrapper">
<div className="maj-terminal-header">
<div className="terminal-dots">
<span className="dot red"></span>
<span className="dot yellow"></span>
<span className="dot green"></span>
</div>
<span className="terminal-title">Journal d'exécution en temps réel (stdout / stderr)</span>
<button type="button" className="btn-copy-logs" onClick={handleCopyLogs}>
Copier les logs
</button>
</div>
<div className="maj-terminal-body" ref={terminalLogsRef}>
{logs.length === 0 ? (
<div className="log-line info">Initialisation du flux de logs...</div>
) : (
logs.map((log, idx) => (
<div key={idx} className={`log-line ${log.type || 'info'}`}>
<span className="log-time">[{log.time}]</span>
<span className="log-text">{log.text}</span>
</div>
))
)}
</div>
</div>
</div>
)}
{/* État 6 : Success (Succès) */}
{status === 'success' && ( {status === 'success' && (
<div className="maj-actions success"> <div className="maj-state-box">
<wcs-mat-icon icon="check_circle" style={{ color: '#16a34a', fontSize: '48px' }}></wcs-mat-icon> <div className="maj-alert-success large">
<p>Mise à jour compilée et déployée avec succès !</p> <wcs-mat-icon icon="verified" style={{ color: '#16a34a', fontSize: '48px' }}></wcs-mat-icon>
<p>L'application va se recharger automatiquement pour appliquer les changements...</p> <div>
<h3 style={{ margin: '0 0 4px', color: '#15803d' }}>
Mise à jour réussie avec succès !
</h3>
<p style={{ margin: '0 0 8px', fontSize: '0.92rem' }}>
Les sources ont été synchronisées, les dépendances installées et l'application a été recompilée.
</p>
{countdown !== null && (
<p style={{ margin: 0, fontWeight: 600, color: '#166534' }}>
Rechargement automatique dans {countdown} seconde(s)...
</p>
)}
</div>
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 10 }}>
<wcs-button className="btn-gradient" onClick={() => window.location.reload()}>
<wcs-mat-icon icon="refresh" size="s"></wcs-mat-icon>
Recharger la page maintenant
</wcs-button>
<wcs-button mode="stroked" onClick={handleReset}>
Fermer &amp; Réinitialiser
</wcs-button>
</div>
</div>
)}
{/* État 7 : Error (Échec) */}
{status === 'error' && (
<div className="maj-state-box">
<div className="maj-alert-error">
<wcs-mat-icon icon="error_outline" style={{ color: '#dc2626', fontSize: '42px' }}></wcs-mat-icon>
<div>
<strong style={{ fontSize: '1.05rem', color: '#991b1b' }}>
Une erreur est survenue pendant la mise à jour
</strong>
<p style={{ margin: '4px 0 0', fontSize: '0.88rem', color: '#7f1d1d' }}>
{errorDetails || "Consultez le terminal ci-dessous pour identifier la commande ayant échoué."}
</p>
</div>
</div>
{/* Affichage des logs d'erreur dans le terminal */}
{logs.length > 0 && (
<div className="maj-terminal-wrapper" style={{ marginTop: 14 }}>
<div className="maj-terminal-header">
<span className="terminal-title">Rapport d'erreur (Derniers logs)</span>
<button type="button" className="btn-copy-logs" onClick={handleCopyLogs}>
Copier les logs
</button>
</div>
<div className="maj-terminal-body" ref={terminalLogsRef}>
{logs.map((log, idx) => (
<div key={idx} className={`log-line ${log.type || 'info'}`}>
<span className="log-time">[{log.time}]</span>
<span className="log-text">{log.text}</span>
</div>
))}
</div>
</div>
)}
<div style={{ marginTop: 16, display: 'flex', gap: 10 }}>
<wcs-button className="btn-gradient" onClick={handleApplyUpdate}>
<wcs-mat-icon icon="refresh" size="s"></wcs-mat-icon>
Réessayer
</wcs-button>
<wcs-button mode="stroked" onClick={handleReset}>
Réinitialiser
</wcs-button>
</div>
</div> </div>
)} )}
</div> </div>
+96
View File
@@ -55,6 +55,102 @@
padding: 14px; padding: 14px;
} }
.tarifs-page-tabs {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.tarifs-page-tab-btn {
border: 1px solid #d8e4ef;
border-radius: 999px;
background: #f3f7fb;
color: #0f3453;
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tarifs-page-tab-btn.active {
background: #0f4f7f;
color: #ffffff;
border-color: #0f4f7f;
}
.tarifs-paliers-panel {
border: 1px solid #dbe7f4;
border-radius: 12px;
background: #ffffff;
padding: 14px;
}
.tarifs-paliers-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.tarifs-paliers-header h3 {
margin: 0 0 6px;
color: #12324e;
}
.tarifs-paliers-header p {
margin: 0;
color: #5d7893;
font-size: 13px;
}
.tarifs-paliers-actions {
display: flex;
align-items: center;
gap: 8px;
}
.tarifs-paliers-table-wrap {
border: 1px solid #d8e4ef;
border-radius: 10px;
overflow: hidden;
}
.tarifs-paliers-table {
width: 100%;
border-collapse: collapse;
}
.tarifs-paliers-table th,
.tarifs-paliers-table td {
border-bottom: 1px solid #e6edf5;
padding: 10px;
text-align: left;
font-size: 13px;
}
.tarifs-paliers-table th {
background: #f3f8ff;
color: #0f3453;
font-weight: 700;
}
.tarifs-paliers-table input {
width: 100%;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
box-sizing: border-box;
}
.tarifs-paliers-empty {
color: #64748b;
text-align: center;
padding: 16px;
}
.tarif-list-grid { .tarif-list-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+277 -5
View File
@@ -3,19 +3,42 @@ import NavPanel from '../../components/nav-panel/NavPanel';
import TarifForm from './TarifForm.js'; import TarifForm from './TarifForm.js';
import TarifList from './TarifList.js'; import TarifList from './TarifList.js';
import { getAllTarifs, deleteTarif } from '../../api/tarifs'; import { getAllTarifs, deleteTarif } from '../../api/tarifs';
import { getParametre, upsertParametre } from '../../api/parametres';
import { canPerform, getCurrentUser, getScopedRegionId } from '../../utils/userUtils'; import { canPerform, getCurrentUser, getScopedRegionId } from '../../utils/userUtils';
const PALIERS_PARAM_KEY = 'tarification_paliers_kilometriques';
function normalizePaliers(input) {
if (!Array.isArray(input)) return [];
return input
.map((p, idx) => ({
id: p?.id || `palier-${Date.now()}-${idx}`,
distance_min_km: Number(p?.distance_min_km ?? 0),
distance_max_km: Number(p?.distance_max_km ?? 0),
prix_billet: Number(p?.prix_billet ?? 0)
}))
.filter((p) => Number.isFinite(p.distance_min_km) && Number.isFinite(p.distance_max_km) && Number.isFinite(p.prix_billet))
.sort((a, b) => a.distance_min_km - b.distance_min_km);
}
export default function Tarifs() { export default function Tarifs() {
const currentUser = getCurrentUser(); const currentUser = getCurrentUser();
const canCreateTarif = canPerform(currentUser, 'tarifs', 'create'); const canCreateTarif = canPerform(currentUser, 'tarifs', 'create');
const canUpdateTarif = canPerform(currentUser, 'tarifs', 'update'); const canUpdateTarif = canPerform(currentUser, 'tarifs', 'update');
const canDeleteTarif = canPerform(currentUser, 'tarifs', 'delete'); const canDeleteTarif = canPerform(currentUser, 'tarifs', 'delete');
const canManagePaliers = canUpdateTarif;
const [tarifs, setTarifs] = useState([]); const [tarifs, setTarifs] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [editing, setEditing] = useState(null); // tarif en édition const [editing, setEditing] = useState(null); // tarif en édition
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [scopedRegionId, setScopedRegionId] = useState(null); const [scopedRegionId, setScopedRegionId] = useState(null);
const [activeTab, setActiveTab] = useState('tarifs');
const [paliers, setPaliers] = useState([]);
const [paliersLoading, setPaliersLoading] = useState(false);
const [paliersSaving, setPaliersSaving] = useState(false);
const [paliersError, setPaliersError] = useState('');
const [paliersSuccess, setPaliersSuccess] = useState('');
const loadTarifs = async () => { const loadTarifs = async () => {
setLoading(true); setLoading(true);
@@ -42,6 +65,131 @@ export default function Tarifs() {
loadTarifs(); loadTarifs();
}, []); }, []);
const loadPaliers = async () => {
setPaliersLoading(true);
setPaliersError('');
try {
const regionId = getScopedRegionId(getCurrentUser()) || null;
const { data, error: err } = await getParametre(PALIERS_PARAM_KEY);
if (err) {
setPaliersError('Erreur lors du chargement des paliers kilométriques.');
setPaliers([]);
} else {
const value = data?.valeur || {};
const source = regionId
? (value?.by_region?.[regionId] || [])
: (value?.default || []);
setPaliers(normalizePaliers(source));
}
} catch (e) {
console.error(e);
setPaliersError('Erreur lors du chargement des paliers kilométriques.');
setPaliers([]);
} finally {
setPaliersLoading(false);
}
};
useEffect(() => {
if (activeTab === 'paliers') loadPaliers();
}, [activeTab]);
const validatePaliers = () => {
if (!Array.isArray(paliers) || paliers.length === 0) {
return 'Ajoutez au moins un palier kilométrique.';
}
const sorted = [...paliers].sort((a, b) => Number(a.distance_min_km) - Number(b.distance_min_km));
for (let i = 0; i < sorted.length; i += 1) {
const row = sorted[i];
if (!Number.isFinite(Number(row.distance_min_km)) || !Number.isFinite(Number(row.distance_max_km)) || !Number.isFinite(Number(row.prix_billet))) {
return 'Chaque palier doit contenir des valeurs numériques valides.';
}
if (Number(row.distance_min_km) < 0 || Number(row.distance_max_km) < 0 || Number(row.prix_billet) < 0) {
return 'Les distances et le prix doivent être positifs.';
}
if (Number(row.distance_max_km) <= Number(row.distance_min_km)) {
return 'La distance max doit être supérieure à la distance min pour chaque palier.';
}
if (i > 0 && Number(row.distance_min_km) < Number(sorted[i - 1].distance_max_km)) {
return 'Les paliers ne doivent pas se chevaucher.';
}
}
return '';
};
const handleSavePaliers = async () => {
if (!canManagePaliers) return;
setPaliersError('');
setPaliersSuccess('');
const validation = validatePaliers();
if (validation) {
setPaliersError(validation);
return;
}
setPaliersSaving(true);
try {
const regionId = getScopedRegionId(getCurrentUser()) || null;
const { data, error: readErr } = await getParametre(PALIERS_PARAM_KEY);
if (readErr) {
setPaliersError(readErr.message || 'Impossible de charger les paramètres existants.');
setPaliersSaving(false);
return;
}
const current = data?.valeur && typeof data.valeur === 'object' ? data.valeur : {};
const normalized = normalizePaliers(paliers);
const payload = {
...current,
by_region: { ...(current.by_region || {}) },
updated_at: new Date().toISOString()
};
if (regionId) {
payload.by_region[regionId] = normalized;
} else {
payload.default = normalized;
}
const { error: saveErr } = await upsertParametre(PALIERS_PARAM_KEY, payload);
if (saveErr) {
setPaliersError(saveErr.message || 'Erreur lors de l’enregistrement des paliers.');
} else {
setPaliersSuccess('Paliers kilométriques enregistrés.');
}
} catch (e) {
console.error(e);
setPaliersError('Erreur inattendue lors de l’enregistrement des paliers.');
} finally {
setPaliersSaving(false);
}
};
const handleAddPalier = () => {
const sorted = [...paliers].sort((a, b) => Number(a.distance_min_km) - Number(b.distance_min_km));
const last = sorted[sorted.length - 1];
const nextMin = last ? Number(last.distance_max_km) : 0;
const nextMax = nextMin + 50;
setPaliers([
...sorted,
{
id: `palier-${Date.now()}`,
distance_min_km: nextMin,
distance_max_km: nextMax,
prix_billet: 0
}
]);
};
const handleRemovePalier = (id) => {
setPaliers((prev) => prev.filter((p) => p.id !== id));
};
const updatePalier = (id, field, value) => {
setPaliers((prev) =>
prev.map((p) => (p.id === id ? { ...p, [field]: value } : p))
);
};
const handleDelete = async (id) => { const handleDelete = async (id) => {
if (!canDeleteTarif) { if (!canDeleteTarif) {
alert('Vous n\'avez pas les droits pour supprimer un tarif.'); alert('Vous n\'avez pas les droits pour supprimer un tarif.');
@@ -100,17 +248,35 @@ export default function Tarifs() {
{scopedRegionId && <p className="tarifs-page-region">Région ciblée: {scopedRegionId}</p>} {scopedRegionId && <p className="tarifs-page-region">Région ciblée: {scopedRegionId}</p>}
</div> </div>
<div className="tarifs-header-actions"> <div className="tarifs-header-actions">
{activeTab === 'tarifs' && (
<wcs-button className="wcs-primary" onClick={handleCreateNew} disabled={!canCreateTarif}> <wcs-button className="wcs-primary" onClick={handleCreateNew} disabled={!canCreateTarif}>
<wcs-mat-icon icon="add" slot="prefix"></wcs-mat-icon> <wcs-mat-icon icon="add" slot="prefix"></wcs-mat-icon>
Nouveau tarif Nouveau tarif
</wcs-button> </wcs-button>
)}
</div> </div>
</header> </header>
<div className="tarifs-main-card"> <div className="tarifs-main-card">
<div className="tarifs-page-tabs">
<button
type="button"
className={`tarifs-page-tab-btn ${activeTab === 'tarifs' ? 'active' : ''}`}
onClick={() => setActiveTab('tarifs')}
>
Tarifs
</button>
<button
type="button"
className={`tarifs-page-tab-btn ${activeTab === 'paliers' ? 'active' : ''}`}
onClick={() => setActiveTab('paliers')}
>
Paliers Kilométriques
</button>
</div>
{/* Modal pour création / édition */} {/* Modal pour création / édition */}
{showModal && editing && ( {activeTab === 'tarifs' && showModal && editing && (
<div className="modal-overlay" onClick={() => { setShowModal(false); setEditing(null); }}> <div className="modal-overlay" onClick={() => { setShowModal(false); setEditing(null); }}>
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 800 }}> <div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 800 }}>
<div className="modal-header"> <div className="modal-header">
@@ -131,12 +297,12 @@ export default function Tarifs() {
</div> </div>
)} )}
{loading ? ( {activeTab === 'tarifs' && loading ? (
<div className="loading-state-full tarifs-state-block"> <div className="loading-state-full tarifs-state-block">
<wcs-spinner mode="border" size="l"></wcs-spinner> <wcs-spinner mode="border" size="l"></wcs-spinner>
<p>Récupération des tarifs...</p> <p>Récupération des tarifs...</p>
</div> </div>
) : error ? ( ) : activeTab === 'tarifs' && error ? (
<div className="error-message-container tarifs-state-block"> <div className="error-message-container tarifs-state-block">
<wcs-message mode="danger"> <wcs-message mode="danger">
<wcs-mat-icon icon="error_outline" slot="prefix"></wcs-mat-icon> <wcs-mat-icon icon="error_outline" slot="prefix"></wcs-mat-icon>
@@ -144,8 +310,115 @@ export default function Tarifs() {
</wcs-message> </wcs-message>
<wcs-button mode="stroked" className="wcs-primary" onClick={loadTarifs}>Réessayer</wcs-button> <wcs-button mode="stroked" className="wcs-primary" onClick={loadTarifs}>Réessayer</wcs-button>
</div> </div>
) : ( ) : activeTab === 'tarifs' ? (
<TarifList tarifs={tarifs} onEdit={handleEdit} onDelete={handleDelete} canUpdate={canUpdateTarif} canDelete={canDeleteTarif} /> <TarifList tarifs={tarifs} onEdit={handleEdit} onDelete={handleDelete} canUpdate={canUpdateTarif} canDelete={canDeleteTarif} />
) : (
<div className="tarifs-paliers-panel">
<div className="tarifs-paliers-header">
<div>
<h3>Paliers Kilométriques</h3>
<p>
Définissez le prix des billets selon des tranches de distance.
{scopedRegionId ? ` Configuration appliquée à la région ${scopedRegionId}.` : ' Configuration globale.'}
</p>
</div>
<div className="tarifs-paliers-actions">
<wcs-button mode="stroked" onClick={loadPaliers} disabled={paliersLoading || paliersSaving}>
Recharger
</wcs-button>
<wcs-button className="wcs-primary" onClick={handleSavePaliers} disabled={!canManagePaliers || paliersSaving || paliersLoading}>
{paliersSaving ? 'Enregistrement...' : 'Enregistrer'}
</wcs-button>
</div>
</div>
{paliersError && (
<wcs-message mode="danger" style={{ marginBottom: 12 }}>
<wcs-mat-icon icon="error_outline" slot="prefix"></wcs-mat-icon>
{paliersError}
</wcs-message>
)}
{paliersSuccess && (
<wcs-message mode="success" style={{ marginBottom: 12 }}>
<wcs-mat-icon icon="check_circle" slot="prefix"></wcs-mat-icon>
{paliersSuccess}
</wcs-message>
)}
{paliersLoading ? (
<div className="loading-state-full tarifs-state-block">
<wcs-spinner mode="border" size="l"></wcs-spinner>
<p>Chargement des paliers kilométriques...</p>
</div>
) : (
<>
<div className="tarifs-paliers-table-wrap">
<table className="tarifs-paliers-table">
<thead>
<tr>
<th>Distance min (km)</th>
<th>Distance max (km)</th>
<th>Prix billet (€)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{paliers.map((palier) => (
<tr key={palier.id}>
<td>
<input
type="number"
min="0"
step="1"
value={palier.distance_min_km}
onChange={(e) => updatePalier(palier.id, 'distance_min_km', e.target.value === '' ? '' : Number(e.target.value))}
disabled={!canManagePaliers}
/>
</td>
<td>
<input
type="number"
min="0"
step="1"
value={palier.distance_max_km}
onChange={(e) => updatePalier(palier.id, 'distance_max_km', e.target.value === '' ? '' : Number(e.target.value))}
disabled={!canManagePaliers}
/>
</td>
<td>
<input
type="number"
min="0"
step="0.01"
value={palier.prix_billet}
onChange={(e) => updatePalier(palier.id, 'prix_billet', e.target.value === '' ? '' : Number(e.target.value))}
disabled={!canManagePaliers}
/>
</td>
<td>
<wcs-button mode="stroked" className="wcs-danger" onClick={() => handleRemovePalier(palier.id)} disabled={!canManagePaliers}>
Supprimer
</wcs-button>
</td>
</tr>
))}
{paliers.length === 0 && (
<tr>
<td colSpan="4" className="tarifs-paliers-empty">
Aucun palier défini.
</td>
</tr>
)}
</tbody>
</table>
</div>
<wcs-button mode="stroked" onClick={handleAddPalier} disabled={!canManagePaliers} style={{ marginTop: 12 }}>
<wcs-mat-icon icon="add" slot="prefix"></wcs-mat-icon>
Ajouter un palier
</wcs-button>
</>
)}
</div>
)} )}
</div> </div>
</div> </div>
@@ -154,4 +427,3 @@ export default function Tarifs() {
); );
} }
File diff suppressed because it is too large. Load diff
+3
View File
@@ -9,6 +9,7 @@ export const RBAC_MODULES = [
'gares', 'gares',
'materiel_roulant', 'materiel_roulant',
'horaires', 'horaires',
'fiches_horaires',
'lignes', 'lignes',
'services', 'services',
'quais', 'quais',
@@ -42,6 +43,7 @@ export const ROLE_PERMISSION_TEMPLATES = {
gares: fullAccess(), gares: fullAccess(),
materiel_roulant: fullAccess(), materiel_roulant: fullAccess(),
horaires: fullAccess(), horaires: fullAccess(),
fiches_horaires: fullAccess(),
lignes: fullAccess(), lignes: fullAccess(),
services: fullAccess(), services: fullAccess(),
quais: fullAccess(), quais: fullAccess(),
@@ -61,6 +63,7 @@ export const ROLE_PERMISSION_TEMPLATES = {
gares: fullAccess(), gares: fullAccess(),
materiel_roulant: fullAccess(), materiel_roulant: fullAccess(),
horaires: fullAccess(), horaires: fullAccess(),
fiches_horaires: fullAccess(),
lignes: fullAccess(), lignes: fullAccess(),
services: fullAccess(), services: fullAccess(),
quais: fullAccess(), quais: fullAccess(),
+401 -48
View File
@@ -1,105 +1,458 @@
const express = require('express'); /**
const cors = require('cors'); * Ferrovia Panel - Service de Mise à Jour Automatique & Gestionnaire Système
const { spawn } = require('child_process'); *
* Serveur autonome Node.js (avec ou sans express) pour orchestrer les opérations
* de maintenance : git fetch/pull, npm install, compilation build et redémarrage PM2.
*/
const http = require('http');
const { spawn, execSync } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs');
const app = express(); const PORT = parseInt(process.env.UPDATER_PORT || process.env.PORT || '4001', 10);
app.use(cors()); const APP_DIR = path.resolve(__dirname);
app.use(express.json());
const PORT = process.env.UPDATER_PORT || 4001; // État global du processus de mise à jour
// State to keep track of the current update process
let updateState = { let updateState = {
status: 'idle', // idle, updating, success, error status: 'idle', // 'idle' | 'checking' | 'updating' | 'success' | 'error'
progress: 0, progress: 0,
step: '', step: '',
error: null,
startedAt: null,
finishedAt: null,
logs: [] logs: []
}; };
function addLog(msg) { /**
console.log(msg); * Ajout d'une ligne de journal horodatée avec typage
updateState.logs.push(msg); */
if (updateState.logs.length > 100) { function addLog(text, type = 'info') {
updateState.logs.shift(); // Keep last 100 logs const timestamp = new Date().toLocaleTimeString('fr-FR', { hour12: false });
const logItem = {
time: timestamp,
text: String(text).trim(),
type: type // 'info' | 'stdout' | 'stderr' | 'success' | 'error' | 'cmd'
};
console.log(`[${timestamp}] [${type.toUpperCase()}] ${logItem.text}`);
updateState.logs.push(logItem);
// Conserver les 300 derniers messages pour éviter la saturation mémoire
if (updateState.logs.length > 300) {
updateState.logs.shift();
} }
} }
function runCommand(command, args, stepName, progressStart, progressEnd) { /**
* Exécute une commande shell de manière asynchrone avec streaming des logs
*/
function runCommand(command, args, stepName, progressStart, progressEnd, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
updateState.step = stepName; updateState.step = stepName;
updateState.progress = progressStart; updateState.progress = progressStart;
addLog(`>>> Exécution: ${command} ${args.join(' ')}`); addLog(`>>> Exécution: ${command} ${args.join(' ')}`, 'cmd');
const proc = spawn(command, args, { cwd: path.join(__dirname), shell: true }); const proc = spawn(command, args, {
cwd: APP_DIR,
shell: true,
env: { ...process.env, ...(options.env || {}) }
});
proc.stdout.on('data', (data) => { proc.stdout.on('data', (data) => {
addLog(data.toString().trim()); const lines = data.toString().split('\n').filter(Boolean);
lines.forEach(line => addLog(line, 'stdout'));
}); });
proc.stderr.on('data', (data) => { proc.stderr.on('data', (data) => {
addLog(`[ERREUR] ${data.toString().trim()}`); const lines = data.toString().split('\n').filter(Boolean);
lines.forEach(line => {
// Certains outils (npm, git) écrivent des infos normales sur stderr
const isRealError = /error|fatal|fail/i.test(line);
addLog(line, isRealError ? 'stderr' : 'info');
});
});
proc.on('error', (err) => {
addLog(`Erreur de lancement de la commande: ${err.message}`, 'error');
reject(err);
}); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code !== 0) { if (code !== 0) {
addLog(`>>> Échec avec le code ${code}`); const errMsg = `La commande "${command} ${args.join(' ')}" a échoué avec le code de sortie ${code}`;
reject(new Error(`La commande ${command} a échoué.`)); addLog(`>>> ${errMsg}`, 'error');
reject(new Error(errMsg));
} else { } else {
updateState.progress = progressEnd; updateState.progress = progressEnd;
addLog(`>>> Succès: ${stepName}`); addLog(`>>> Étape réussie : ${stepName}`, 'success');
resolve(); resolve();
} }
}); });
}); });
} }
app.get('/api/update/status', (req, res) => { /**
res.json(updateState); * Récupère les métadonnées de version et de dépôt local
}); */
function getLocalSystemInfo() {
let version = '0.1.0';
try {
const pkgJson = JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8'));
version = pkgJson.version || version;
} catch (e) {
// Ignorer si package.json non lisible
}
app.post('/api/update', async (req, res) => { let branch = 'main';
let commitHash = '';
let commitFullHash = '';
let commitMessage = '';
let commitDate = '';
let commitAuthor = '';
let remoteUrl = '';
try {
branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim();
} catch (e) {
branch = 'inconnu';
}
try {
commitHash = execSync('git rev-parse --short HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim();
commitFullHash = execSync('git rev-parse HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim();
commitMessage = execSync('git log -1 --pretty=%B', { cwd: APP_DIR, encoding: 'utf8' }).trim();
commitDate = execSync('git log -1 --format=%cd --date=format:"%d/%m/%Y %H:%M:%S"', { cwd: APP_DIR, encoding: 'utf8' }).trim();
commitAuthor = execSync('git log -1 --format=%an', { cwd: APP_DIR, encoding: 'utf8' }).trim();
} catch (e) {
// Ignorer si non disponible
}
try {
remoteUrl = execSync('git config --get remote.origin.url', { cwd: APP_DIR, encoding: 'utf8' }).trim();
} catch (e) {
remoteUrl = '';
}
return {
version,
branch,
commitHash,
commitFullHash,
commitMessage,
commitDate,
commitAuthor,
remoteUrl,
nodeVersion: process.version,
platform: process.platform,
pid: process.pid,
uptime: Math.round(process.uptime())
};
}
/**
* Vérifie l'état des mises à jour sur le dépôt Git distant
*/
async function checkGitUpdates() {
try {
// 1. Fetch silencieux
execSync('git fetch origin', { cwd: APP_DIR, encoding: 'utf8', timeout: 15000 });
let branch = 'main';
try {
branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim();
} catch (e) {}
// 2. Compter les commits de retard
let commitsBehind = 0;
try {
const behindCountStr = execSync(`git rev-list --count HEAD..origin/${branch}`, { cwd: APP_DIR, encoding: 'utf8' }).trim();
commitsBehind = parseInt(behindCountStr, 10) || 0;
} catch (e) {
// Si pas de branche distante correspondante
commitsBehind = 0;
}
// 3. Récupérer la liste des commits entrants
let incomingCommits = [];
if (commitsBehind > 0) {
try {
const logOutput = execSync(`git log HEAD..origin/${branch} --format="%h|%an|%ar|%ad|%s" --date=format:"%d/%m/%Y %H:%M" -n 25`, {
cwd: APP_DIR,
encoding: 'utf8'
}).trim();
if (logOutput) {
incomingCommits = logOutput.split('\n').map(line => {
const [hash, author, relativeDate, date, ...msgParts] = line.split('|');
return {
hash,
author,
relativeDate,
date,
message: msgParts.join('|')
};
});
}
} catch (e) {
// Ignore parsing errors
}
}
const localInfo = getLocalSystemInfo();
return {
success: true,
hasUpdate: commitsBehind > 0,
commitsBehind,
branch,
incomingCommits,
localInfo,
checkedAt: new Date().toISOString()
};
} catch (err) {
return {
success: false,
hasUpdate: false,
error: `Impossible de contacter le dépôt distant : ${err.message}`,
localInfo: getLocalSystemInfo(),
checkedAt: new Date().toISOString()
};
}
}
/**
* Pipeline d'exécution de la mise à jour
*/
async function executeUpdatePipeline(options = {}) {
if (updateState.status === 'updating') { if (updateState.status === 'updating') {
return res.status(400).json({ error: 'Une mise à jour est déjà en cours.' }); throw new Error('Une mise à jour est déjà en cours.');
} }
updateState = { updateState = {
status: 'updating', status: 'updating',
progress: 10, progress: 5,
step: 'Initialisation', step: 'Initialisation de la mise à jour',
error: null,
startedAt: new Date().toISOString(),
finishedAt: null,
logs: [] logs: []
}; };
res.json({ message: 'Mise à jour démarrée' }); addLog('🚀 Démarrage du pipeline de mise à jour Ferrovia Panel...', 'info');
addLog(`Répertoire applicatif : ${APP_DIR}`, 'info');
try { try {
// Step 1: Git Pull (10% to 30%) // Étape 1 : Récupération Git (5% -> 35%)
await runCommand('git', ['pull'], 'Téléchargement des sources (git pull)', 10, 30); let branch = 'main';
try {
branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: APP_DIR, encoding: 'utf8' }).trim();
} catch (e) {}
// Step 2: NPM Install (30% to 60%) await runCommand('git', ['fetch', 'origin'], 'Synchronisation des métadonnées Git', 10, 20);
await runCommand('npm', ['install'], 'Installation des dépendances (npm install)', 30, 60);
// Step 3: Build (60% to 100%) // Si modifications locales non commitées, faire un stash temporaire sécurisé
await runCommand('npm', ['run', 'build'], 'Compilation de l\'application (npm run build)', 60, 100); let hasStashed = false;
try {
const statusOutput = execSync('git status --porcelain', { cwd: APP_DIR, encoding: 'utf8' }).trim();
if (statusOutput && options.stash !== false) {
addLog('Modifications locales détectées, mise en réserve (git stash)...', 'info');
execSync('git stash', { cwd: APP_DIR, encoding: 'utf8' });
hasStashed = true;
}
} catch (e) {}
await runCommand('git', ['pull', 'origin', branch], `Téléchargement des dernières modifications (branche ${branch})`, 20, 40);
// Étape 2 : Installation des dépendances NPM (40% -> 65%)
if (options.skipNpmInstall !== true) {
await runCommand('npm', ['install', '--no-audit', '--no-fund'], 'Installation & mise à jour des dépendances (npm install)', 40, 65);
} else {
addLog('Étape npm install ignorée selon les paramètres.', 'info');
updateState.progress = 65;
}
// Étape 3 : Compilation de l'application React (65% -> 90%)
if (options.skipBuild !== true) {
await runCommand('npm', ['run', 'build'], 'Compilation de l\'application (npm run build)', 65, 90);
} else {
addLog('Étape de build ignorée selon les paramètres.', 'info');
updateState.progress = 90;
}
// Étape 4 : Détection PM2 & Redémarrage (90% -> 100%)
updateState.step = 'Finalisation du déploiement';
updateState.progress = 95;
let pm2Reloaded = false;
if (options.restartPm2 !== false) {
try {
// Tester si PM2 est actif
execSync('pm2 --version', { stdio: 'ignore' });
addLog('PM2 détecté sur le système. Tentative de rechargement...', 'info');
try {
execSync('pm2 reload all || pm2 restart all', { cwd: APP_DIR, encoding: 'utf8', stdio: 'ignore' });
addLog('Processus PM2 rechargés avec succès.', 'success');
pm2Reloaded = true;
} catch (e) {
addLog(`Avertissement PM2 : ${e.message}`, 'info');
}
} catch (e) {
addLog('PM2 non présent ou non configuré pour ce serveur.', 'info');
}
}
updateState.status = 'success'; updateState.status = 'success';
updateState.step = 'Terminé'; updateState.step = 'Mise à jour terminée avec succès';
updateState.progress = 100; updateState.progress = 100;
addLog('>>> Mise à jour terminée avec succès.'); updateState.finishedAt = new Date().toISOString();
addLog('🎉 Félicitations ! Ferrovia Panel a été mis à jour avec succès.', 'success');
} catch (error) { return { success: true, pm2Reloaded };
} catch (err) {
updateState.status = 'error'; updateState.status = 'error';
updateState.step = 'Erreur'; updateState.step = 'Échec de la mise à jour';
addLog(`>>> ERREUR CRITIQUE: ${error.message}`); updateState.error = err.message;
updateState.finishedAt = new Date().toISOString();
addLog(`❌ ERREUR CRITIQUE : ${err.message}`, 'error');
throw err;
}
}
/**
* Serveur HTTP natif Node.js avec gestion universelle des en-têtes CORS et JSON
*/
const server = http.createServer(async (req, res) => {
// 1. En-têtes CORS complets
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
res.setHeader('Access-Control-Max-Age', '86400');
// Répondre aux requêtes preflight OPTIONS
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
const sendJson = (statusCode, data) => {
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
};
const parseBody = () => {
return new Promise((resolve) => {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (e) {
resolve({});
}
});
});
};
try {
// --- ROUTE: Health check ---
if (pathname === '/api/health' || pathname === '/api/update/health') {
return sendJson(200, {
status: 'ok',
service: 'ferrovia-updater',
version: '2.0.0',
uptime: Math.round(process.uptime()),
pid: process.pid,
port: PORT,
appDir: APP_DIR
});
}
// --- ROUTE: Local System Information ---
if (pathname === '/api/update/info' && req.method === 'GET') {
const info = getLocalSystemInfo();
return sendJson(200, info);
}
// --- ROUTE: Check Remote Updates ---
if ((pathname === '/api/update/check' || pathname === '/api/check-update') && req.method === 'GET') {
const checkResult = await checkGitUpdates();
return sendJson(200, checkResult);
}
// --- ROUTE: Start Update Pipeline ---
if ((pathname === '/api/update/start' || pathname === '/api/update') && req.method === 'POST') {
if (updateState.status === 'updating') {
return sendJson(409, {
error: 'Une mise à jour est déjà en cours d\'exécution.',
state: updateState
});
}
const body = await parseBody();
// Démarrage asynchrone pour répondre immédiatement au client
executeUpdatePipeline(body).catch(err => {
console.error('[UPDATER_ERROR]', err);
});
return sendJson(202, {
message: 'Le processus de mise à jour a été initialisé.',
status: updateState.status,
startedAt: updateState.startedAt
});
}
// --- ROUTE: Real-time Update Status ---
if (pathname === '/api/update/status' && req.method === 'GET') {
return sendJson(200, updateState);
}
// --- ROUTE: Reset State ---
if (pathname === '/api/update/reset' && req.method === 'POST') {
if (updateState.status === 'updating') {
return sendJson(400, { error: 'Impossible de réinitialiser pendant qu\'une mise à jour s\'exécute.' });
}
updateState = {
status: 'idle',
progress: 0,
step: '',
error: null,
startedAt: null,
finishedAt: null,
logs: []
};
return sendJson(200, { message: 'État réinitialisé avec succès.', state: updateState });
}
// 404 Route non trouvée
return sendJson(404, { error: `Route introuvable : ${req.method} ${pathname}` });
} catch (err) {
console.error('[UPDATER_SERVER_ERROR]', err);
return sendJson(500, { error: err.message || 'Erreur interne du serveur de mise à jour' });
} }
}); });
// Acknowledge update success and reset server.listen(PORT, '0.0.0.0', () => {
app.post('/api/update/reset', (req, res) => { console.log(`====================================================`);
updateState = { status: 'idle', progress: 0, step: '', logs: [] }; console.log(`🚆 FERROVIA PANEL - UPDATER DAEMON DÉMARRÉ`);
res.json({ message: 'Reset OK' }); console.log(`📡 Port d'écoute : http://localhost:${PORT}`);
console.log(`📁 Répertoire : ${APP_DIR}`);
console.log(`====================================================`);
}); });
app.listen(PORT, () => { // Arrêt propre
console.log(`[Ferrovia Updater] Écoute sur le port ${PORT}`); process.on('SIGTERM', () => {
console.log('Fermeture du serveur updater (SIGTERM)...');
server.close(() => process.exit(0));
});
process.on('SIGINT', () => {
console.log('Fermeture du serveur updater (SIGINT)...');
server.close(() => process.exit(0));
}); });