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:
1 parent
37ab831aac
commit
1c68740e41
25 files changed
+13363
-526
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');
|
||||
Generated
+66
-4
@@ -16,6 +16,7 @@
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"jspdf": "^2.5.2",
|
||||
"jszip": "^3.10.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-scripts": "5.0.1",
|
||||
@@ -12923,6 +12924,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "9.0.21",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||
@@ -14986,6 +14993,54 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.2",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
|
||||
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -15072,6 +15127,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
@@ -16462,8 +16526,7 @@
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)",
|
||||
"peer": true
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parallel-transform": {
|
||||
"version": "1.2.0",
|
||||
@@ -19915,8 +19978,7 @@
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
|
||||
+3
-1
@@ -11,6 +11,7 @@
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"jspdf": "^2.5.2",
|
||||
"jszip": "^3.10.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-scripts": "5.0.1",
|
||||
@@ -23,7 +24,8 @@
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"eject": "react-scripts eject",
|
||||
"updater": "node updater.js"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' } };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -386,28 +386,40 @@ const handleSave = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const INP = { width: '100%', padding: '9px 12px', border: '1px solid var(--wcs-gray-300, #d1d5db)', borderRadius: 6, fontSize: 14, boxSizing: 'border-box', outline: 'none', fontFamily: 'inherit', background: 'inherit', color: 'inherit' };
|
||||
const LBL = { display: 'block', fontWeight: 600, fontSize: 13, color: 'inherit', marginBottom: 6 };
|
||||
const SEC = { fontSize: 13, fontWeight: 700, color: 'inherit', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 12px', paddingBottom: 6, borderBottom: '1px solid var(--wcs-gray-200, #e5e7eb)' };
|
||||
const TABS = ['general', 'gares', 'jours', 'materiel', 'substitution'];
|
||||
const TAB_LABELS = { general: 'Général', gares: 'Gares desservies', jours: 'Calendrier de circulation', materiel: 'Matériel roulant', substitution: 'Substitution' };
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={closeModal}>
|
||||
<div
|
||||
className="modal-content modal-xlarge"
|
||||
className="modal-content modal-xlarge horaire-modal-shell"
|
||||
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">
|
||||
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 700 }}>
|
||||
{isDuplicating ? '📋 Dupliquer l\'horaire' : editingItem ? ' Modifier l\'horaire' : 'Nouvel horaire'}
|
||||
</h3>
|
||||
<div className="modal-header horaire-modal-header">
|
||||
<div>
|
||||
<div className="horaire-modal-kicker">Gestion du sillon</div>
|
||||
<h3>
|
||||
{isDuplicating ? 'Dupliquer l\'horaire' : editingItem ? 'Modifier l\'horaire' : 'Nouvel horaire'}
|
||||
</h3>
|
||||
</div>
|
||||
<button className="modal-close" onClick={closeModal}>×</button>
|
||||
</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">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
@@ -420,7 +432,6 @@ const handleSave = async () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Body (scrollable) ── */}
|
||||
<div className="horaires-modal-body-scroll">
|
||||
{formError && (
|
||||
<div className="horaires-form-error">
|
||||
@@ -428,140 +439,143 @@ const handleSave = async () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ TAB GÉNÉRAL ══ */}
|
||||
{activeTab === 'general' && (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
|
||||
<div>
|
||||
<label style={LBL}>Numéro de train <span style={{ color: '#dc2626' }}>*</span></label>
|
||||
<input style={INP} type="text" value={numeroTrain} onChange={e => setNumeroTrain(e.target.value)} placeholder="Ex: TER 12345" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LBL}>Type de train</label>
|
||||
<select
|
||||
style={{ width: '100%', padding: 10 }}
|
||||
value={typeTrainId}
|
||||
onChange={e => {
|
||||
const id = e.target.value;
|
||||
const sel = typesTrainsList.find(t => String(t.id) === String(id));
|
||||
setTypeTrainId(id);
|
||||
setTypeTrainNom(sel ? sel.nom : '');
|
||||
}}
|
||||
>
|
||||
<option value="">— Sélectionner un type —</option>
|
||||
{typesTrainsList.map(t => (
|
||||
<option key={t.id} value={String(t.id)}>{t.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
{typeTrainNom && (
|
||||
<div style={{ fontSize: 12, color: '#0088ce', marginTop: 4 }}>✓ {typeTrainNom}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
|
||||
<div>
|
||||
<label style={LBL}>Ligne</label>
|
||||
<select style={INP} value={ligne} onChange={e => setLigne(e.target.value)}>
|
||||
<option value="">— Aucune ligne —</option>
|
||||
{lignes.map(l => (
|
||||
<option key={l.id} value={String(l.id)}>{l.nom}{l.code ? ` (${l.code})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LBL}>Service Annuel</label>
|
||||
<select
|
||||
style={{ width: '100%', padding: 10 }}
|
||||
value={serviceAnnuelId}
|
||||
onChange={e => {
|
||||
const id = e.target.value;
|
||||
const sel = servicesAnnuels.find(s => String(s.id) === String(id));
|
||||
setServiceAnnuelId(id);
|
||||
setServiceAnnuelNom(sel ? sel.nom : '');
|
||||
}}
|
||||
>
|
||||
<option value="">— Aucun service —</option>
|
||||
{servicesAnnuels.map(s => (
|
||||
<option key={s.id} value={String(s.id)}>{s.nom} ({formatDate(s.date_debut)} → {formatDate(s.date_fin)})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={SEC}>Trajet</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 12, marginBottom: 14, alignItems: 'end' }}>
|
||||
<div>
|
||||
<label style={LBL}>Gare de départ <span style={{ color: '#dc2626' }}>*</span></label>
|
||||
<div className="horaires-gare-autocomplete">
|
||||
<input
|
||||
style={INP}
|
||||
value={gareDepartNom || ''}
|
||||
onFocus={() => setGareSuggestionsOpenFor('depart')}
|
||||
onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === 'depart' ? null : prev), 120)}
|
||||
onChange={e => applyMainGareSelection(e.target.value, setGareDepartId, setGareDepartNom)}
|
||||
placeholder="Rechercher une gare..."
|
||||
/>
|
||||
{gareSuggestionsOpenFor === 'depart' && getMatchingGares(gareDepartNom).length > 0 && (
|
||||
<div className="horaires-gare-suggestions">
|
||||
{getMatchingGares(gareDepartNom).map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
className="horaires-gare-suggestion-item"
|
||||
onMouseDown={() => {
|
||||
setGareDepartId(String(g.id));
|
||||
setGareDepartNom(g.nom || '');
|
||||
}}
|
||||
>
|
||||
{g.nom}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="horaire-modal-form">
|
||||
<div className="horaire-modal-section-card">
|
||||
<div className="horaire-modal-section-header">Identification</div>
|
||||
<div className="horaire-modal-grid horaire-modal-grid-two">
|
||||
<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 className="horaire-form-field">
|
||||
<label className="horaire-label">Type de train</label>
|
||||
<select
|
||||
className="horaire-select"
|
||||
value={typeTrainId}
|
||||
onChange={e => {
|
||||
const id = e.target.value;
|
||||
const sel = typesTrainsList.find(t => String(t.id) === String(id));
|
||||
setTypeTrainId(id);
|
||||
setTypeTrainNom(sel ? sel.nom : '');
|
||||
}}
|
||||
>
|
||||
<option value="">— Sélectionner un type —</option>
|
||||
{typesTrainsList.map(t => (
|
||||
<option key={t.id} value={String(t.id)}>{t.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
{typeTrainNom && (
|
||||
<div className="horaire-field-helper">✓ {typeTrainNom}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LBL}>Heure départ <span style={{ color: '#dc2626' }}>*</span></label>
|
||||
<input style={INP} type="time" value={heureDepart} onChange={e => setHeureDepart(e.target.value)} />
|
||||
|
||||
<div className="horaire-modal-grid horaire-modal-grid-two">
|
||||
<div className="horaire-form-field">
|
||||
<label className="horaire-label">Ligne</label>
|
||||
<select className="horaire-select" value={ligne} onChange={e => setLigne(e.target.value)}>
|
||||
<option value="">— Aucune ligne —</option>
|
||||
{lignes.map(l => (
|
||||
<option key={l.id} value={String(l.id)}>{l.nom}{l.code ? ` (${l.code})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="horaire-form-field">
|
||||
<label className="horaire-label">Service Annuel</label>
|
||||
<select
|
||||
className="horaire-select"
|
||||
value={serviceAnnuelId}
|
||||
onChange={e => {
|
||||
const id = e.target.value;
|
||||
const sel = servicesAnnuels.find(s => String(s.id) === String(id));
|
||||
setServiceAnnuelId(id);
|
||||
setServiceAnnuelNom(sel ? sel.nom : '');
|
||||
}}
|
||||
>
|
||||
<option value="">— Aucun service —</option>
|
||||
{servicesAnnuels.map(s => (
|
||||
<option key={s.id} value={String(s.id)}>{s.nom} ({formatDate(s.date_debut)} → {formatDate(s.date_fin)})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 12, marginBottom: 14, alignItems: 'end' }}>
|
||||
<div>
|
||||
<label style={LBL}>Gare d'arrivée <span style={{ color: '#dc2626' }}>*</span></label>
|
||||
<div className="horaires-gare-autocomplete">
|
||||
<input
|
||||
style={INP}
|
||||
value={gareArriveeNom || ''}
|
||||
onFocus={() => setGareSuggestionsOpenFor('arrivee')}
|
||||
onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === 'arrivee' ? null : prev), 120)}
|
||||
onChange={e => applyMainGareSelection(e.target.value, setGareArriveeId, setGareArriveeNom)}
|
||||
placeholder="Rechercher une gare..."
|
||||
/>
|
||||
{gareSuggestionsOpenFor === 'arrivee' && getMatchingGares(gareArriveeNom).length > 0 && (
|
||||
<div className="horaires-gare-suggestions">
|
||||
{getMatchingGares(gareArriveeNom).map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
className="horaires-gare-suggestion-item"
|
||||
onMouseDown={() => {
|
||||
setGareArriveeId(String(g.id));
|
||||
setGareArriveeNom(g.nom || '');
|
||||
}}
|
||||
>
|
||||
{g.nom}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="horaire-modal-section-card">
|
||||
<div className="horaire-modal-section-header">Trajet</div>
|
||||
<div className="horaire-modal-grid horaire-modal-grid-journey">
|
||||
<div className="horaire-form-field">
|
||||
<label className="horaire-label">Gare de départ <span>*</span></label>
|
||||
<div className="horaires-gare-autocomplete">
|
||||
<input
|
||||
className="horaire-input"
|
||||
value={gareDepartNom || ''}
|
||||
onFocus={() => setGareSuggestionsOpenFor('depart')}
|
||||
onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === 'depart' ? null : prev), 120)}
|
||||
onChange={e => applyMainGareSelection(e.target.value, setGareDepartId, setGareDepartNom)}
|
||||
placeholder="Rechercher une gare..."
|
||||
/>
|
||||
{gareSuggestionsOpenFor === 'depart' && getMatchingGares(gareDepartNom).length > 0 && (
|
||||
<div className="horaires-gare-suggestions">
|
||||
{getMatchingGares(gareDepartNom).map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
className="horaires-gare-suggestion-item"
|
||||
onMouseDown={() => {
|
||||
setGareDepartId(String(g.id));
|
||||
setGareDepartNom(g.nom || '');
|
||||
}}
|
||||
>
|
||||
{g.nom}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="horaire-form-field">
|
||||
<label className="horaire-label">Heure départ <span>*</span></label>
|
||||
<input className="horaire-input" type="time" value={heureDepart} onChange={e => setHeureDepart(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={LBL}>Heure arrivée <span style={{ color: '#dc2626' }}>*</span></label>
|
||||
<input style={INP} type="time" value={heureArrivee} onChange={e => setHeureArrivee(e.target.value)} />
|
||||
|
||||
<div className="horaire-modal-grid horaire-modal-grid-journey">
|
||||
<div className="horaire-form-field">
|
||||
<label className="horaire-label">Gare d'arrivée <span>*</span></label>
|
||||
<div className="horaires-gare-autocomplete">
|
||||
<input
|
||||
className="horaire-input"
|
||||
value={gareArriveeNom || ''}
|
||||
onFocus={() => setGareSuggestionsOpenFor('arrivee')}
|
||||
onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === 'arrivee' ? null : prev), 120)}
|
||||
onChange={e => applyMainGareSelection(e.target.value, setGareArriveeId, setGareArriveeNom)}
|
||||
placeholder="Rechercher une gare..."
|
||||
/>
|
||||
{gareSuggestionsOpenFor === 'arrivee' && getMatchingGares(gareArriveeNom).length > 0 && (
|
||||
<div className="horaires-gare-suggestions">
|
||||
{getMatchingGares(gareArriveeNom).map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
className="horaires-gare-suggestion-item"
|
||||
onMouseDown={() => {
|
||||
setGareArriveeId(String(g.id));
|
||||
setGareArriveeNom(g.nom || '');
|
||||
}}
|
||||
>
|
||||
{g.nom}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="horaire-form-field">
|
||||
<label className="horaire-label">Heure arrivée <span>*</span></label>
|
||||
<input className="horaire-input" type="time" value={heureArrivee} onChange={e => setHeureArrivee(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -586,10 +600,10 @@ const handleSave = async () => {
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<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">
|
||||
<input
|
||||
style={INP}
|
||||
className="horaire-input"
|
||||
value={g.gare_nom || ''}
|
||||
onFocus={() => setGareSuggestionsOpenFor(`desservie-${idx}`)}
|
||||
onBlur={() => setTimeout(() => setGareSuggestionsOpenFor(prev => prev === `desservie-${idx}` ? null : prev), 120)}
|
||||
@@ -621,12 +635,12 @@ const handleSave = async () => {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: '1 1 110px' }}>
|
||||
<label style={{ ...LBL, marginBottom: 4 }}>Arrivée</label>
|
||||
<input style={INP} type="time" value={g.heure_arrivee || ''} onChange={e => updateGareDesservieField(idx, 'heure_arrivee', e.target.value)} />
|
||||
<label className="horaire-label" style={{ marginBottom: 4 }}>Arrivée</label>
|
||||
<input className="horaire-input" type="time" value={g.heure_arrivee || ''} onChange={e => updateGareDesservieField(idx, 'heure_arrivee', e.target.value)} />
|
||||
</div>
|
||||
<div style={{ flex: '1 1 110px' }}>
|
||||
<label style={{ ...LBL, marginBottom: 4 }}>Départ</label>
|
||||
<input style={INP} type="time" value={g.heure_depart || ''} onChange={e => updateGareDesservieField(idx, 'heure_depart', e.target.value)} />
|
||||
<label className="horaire-label" style={{ marginBottom: 4 }}>Départ</label>
|
||||
<input className="horaire-input" type="time" value={g.heure_depart || ''} onChange={e => updateGareDesservieField(idx, 'heure_depart', e.target.value)} />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,8 @@ const MENU_GROUPS = [
|
||||
label: 'Diffusion',
|
||||
items: [
|
||||
{ label: "Gestion d'informations", href: '/informations', match: ['/informations'] },
|
||||
{ label: 'Annonces sonores', href: '/annonces-sonores', match: ['/annonces-sonores'] }
|
||||
{ label: 'Annonces sonores', href: '/annonces-sonores', match: ['/annonces-sonores'] },
|
||||
{ label: 'Fiches horaires', href: '/fiches-horaires', match: ['/fiches-horaires'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -330,9 +331,10 @@ export default function AppHeader({ isLoggedIn, userName }) {
|
||||
else if (section === 'gares') permSection = 'gares';
|
||||
else if (section === 'quais') permSection = 'quais';
|
||||
else if (section === 'annonces-sonores') permSection = 'annonces_sonores';
|
||||
else if (section === 'fiches-horaires') permSection = 'fiches_horaires';
|
||||
else if (section === 'systeme-maj') permSection = 'systeme_maj';
|
||||
|
||||
return hasAccessTo(user, permSection);
|
||||
return hasAccessTo(user, permSection) || (permSection === 'fiches_horaires' && hasAccessTo(user, 'horaires'));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -50,6 +50,13 @@ export default function NavPanel() {
|
||||
</a>
|
||||
</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' : ''}>
|
||||
<a href="/informations" aria-current={isActive('/informations') ? 'page' : undefined}>
|
||||
<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
@@ -2,9 +2,32 @@ import React, { useState, useMemo } from 'react';
|
||||
import NavPanel from '../../components/nav-panel/NavPanel';
|
||||
import { getAllGares } from '../../api/gares';
|
||||
|
||||
// TODO: module api/gtfs non implémenté — fonctions stubées pour éviter le crash de build
|
||||
const analyzeGTFSZip = async () => { throw new Error('analyzeGTFSZip non implémenté'); };
|
||||
const importGares = async () => { throw new Error('importGares non implémenté'); };
|
||||
import { parseGTFSZip } from '../../api/gtfs';
|
||||
import { createGare } from '../../api/gares';
|
||||
|
||||
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() {
|
||||
const [file, setFile] = useState(null);
|
||||
|
||||
@@ -829,6 +829,216 @@
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function Horaires() {
|
||||
const [garesTracees, setGaresTracees] = useState([]);
|
||||
const [ligneFHUrl, setLigneFHUrl] = useState('');
|
||||
const [ligneFHFile, setLigneFHFile] = useState(null);
|
||||
const [ligneFHUploading, setLigneFHUploading] = useState(false);
|
||||
const [ligneFHUploading] = useState(false);
|
||||
const [ligneFHError, setLigneFHError] = useState('');
|
||||
const [ligneModalTab, setLigneModalTab] = useState('infos');
|
||||
const [ligneRegion, setLigneRegion] = useState('');
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+4021
-81
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,7 @@ import Logout from '../logout/Logout';
|
||||
import MaterielRoulant from '../materiel-roulant/MaterielRoulant';
|
||||
import ImportsPage from '../horaires/ImportsPage';
|
||||
import Horaires from '../horaires/Horaires';
|
||||
import FichesHoraires from '../fiches-horaires/FichesHoraires';
|
||||
import Parametres from "../parametres/Parametres";
|
||||
import Perturbations from "../perturbations/Perturbations";
|
||||
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('/attribution-materiel-roulant')) return denyIfNoAccess('horaires') || <AttributionMaterielRoulant />;
|
||||
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('/perturbations/creer')) return denyIfNoAccess('perturbations', 'create') || <CreerPerturbation />;
|
||||
if (path.startsWith('/perturbations/modifier/')) return denyIfNoAccess('perturbations', 'update') || <ModifierPerturbation />;
|
||||
|
||||
@@ -1,179 +1,707 @@
|
||||
.maj-content {
|
||||
/* Ferrovia Panel - Styles Système de Mises à Jour */
|
||||
|
||||
.systeme-maj-page .maj-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.maj-card {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
/* Badges de connexion au service */
|
||||
.maj-service-badge {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.maj-actions p {
|
||||
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;
|
||||
.maj-alert-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
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;
|
||||
.maj-alert-success.large {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
body.dark-mode .maj-header,
|
||||
[data-theme="dark"] .maj-header {
|
||||
border-bottom-color: #243247;
|
||||
.maj-alert-warning {
|
||||
display: flex;
|
||||
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,
|
||||
[data-theme="dark"] .maj-title h3 {
|
||||
color: #f8fafc;
|
||||
.maj-alert-error {
|
||||
display: flex;
|
||||
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,
|
||||
[data-theme="dark"] .maj-actions p {
|
||||
.maj-inline-error {
|
||||
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;
|
||||
}
|
||||
|
||||
body.dark-mode .alert-new-version,
|
||||
[data-theme="dark"] .alert-new-version {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: #f59e0b;
|
||||
/* Floating Toast */
|
||||
.maj-floating-toast {
|
||||
position: fixed;
|
||||
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,
|
||||
[data-theme="dark"] .alert-new-version strong {
|
||||
color: #fde68a;
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
body.dark-mode .alert-new-version p,
|
||||
[data-theme="dark"] .alert-new-version p {
|
||||
color: #fcd34d;
|
||||
/* Dark Mode Theme Support */
|
||||
body.dark-mode .maj-card,
|
||||
[data-theme="dark"] .maj-card {
|
||||
background: #151f30;
|
||||
border-color: #243247;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
body.dark-mode .progress-text,
|
||||
[data-theme="dark"] .progress-text {
|
||||
body.dark-mode .maj-card-title,
|
||||
[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;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.maj-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.alert-new-version {
|
||||
flex-direction: column;
|
||||
}
|
||||
body.dark-mode .branch-tag,
|
||||
[data-theme="dark"] .branch-tag {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
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 [version, setVersion] = useState('v0.1.0 (Bêta)');
|
||||
const [newVersion, setNewVersion] = useState(null);
|
||||
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [stepName, setStepName] = useState('');
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [errorDetails, setErrorDetails] = useState(null);
|
||||
const [countdown, setCountdown] = useState(null);
|
||||
|
||||
const checkForUpdates = () => {
|
||||
setStatus('checking');
|
||||
setTimeout(() => {
|
||||
// Pour une vérification réelle, on pourrait interroger un endpoint /api/check-update
|
||||
setNewVersion('Dernière version (Git)');
|
||||
setStatus('available');
|
||||
}, 1500);
|
||||
};
|
||||
// Options
|
||||
const [optionSkipBuild, setOptionSkipBuild] = useState(false);
|
||||
const [optionRestartPm2, setOptionRestartPm2] = useState(true);
|
||||
const [optionStash, setOptionStash] = useState(true);
|
||||
|
||||
const applyUpdate = async () => {
|
||||
setStatus('updating');
|
||||
setProgress(0);
|
||||
// Toast / Copy notification
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 1. Diagnostic de santé du service updater
|
||||
const verifyServiceHealth = useCallback(async () => {
|
||||
setCheckingHealth(true);
|
||||
try {
|
||||
// Démarre la mise à jour sur le backend
|
||||
await fetch('http://localhost:3001/api/update', { method: 'POST' });
|
||||
const health = await checkUpdaterHealth();
|
||||
setServiceHealth({
|
||||
...health,
|
||||
checkedAt: new Date().toLocaleTimeString('fr-FR')
|
||||
});
|
||||
|
||||
// Polling de l'état
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:3001/api/update/status');
|
||||
const data = await res.json();
|
||||
if (health.isOnline) {
|
||||
// Charger les informations système
|
||||
const info = await fetchSystemInfo();
|
||||
setSystemInfo(prev => ({ ...prev, ...info }));
|
||||
}
|
||||
} catch (err) {
|
||||
setServiceHealth({
|
||||
isOnline: false,
|
||||
latency: null,
|
||||
data: null,
|
||||
error: err.message,
|
||||
checkedAt: new Date().toLocaleTimeString('fr-FR')
|
||||
});
|
||||
} finally {
|
||||
setCheckingHealth(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
setProgress(data.progress);
|
||||
setStepName(data.step);
|
||||
setLogs(data.logs || []);
|
||||
useEffect(() => {
|
||||
verifyServiceHealth();
|
||||
}, [verifyServiceHealth]);
|
||||
|
||||
if (data.status === 'success') {
|
||||
clearInterval(interval);
|
||||
setStatus('success');
|
||||
setVersion('À jour');
|
||||
setNewVersion(null);
|
||||
// 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 }));
|
||||
|
||||
// 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) {
|
||||
console.error("Erreur de connexion avec l'updater:", err);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("Impossible de lancer la mise à jour:", error);
|
||||
setStatus('error');
|
||||
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);
|
||||
|
||||
} else if (data.status === 'error') {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
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 (
|
||||
<div className="panel-layout">
|
||||
<div className="sncf-groupe panel-layout systeme-maj-page">
|
||||
<div className="nav-panel">
|
||||
<wcs-nav>
|
||||
<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>
|
||||
</wcs-nav-item>
|
||||
</wcs-nav>
|
||||
</div>
|
||||
|
||||
<div className="panel-content maj-content">
|
||||
<div className="page-header">
|
||||
<h2>Système de mises à jour</h2>
|
||||
<span className="station-type-badge ville" style={{ background: '#0088ce', color: '#fff' }}>Espace Administrateur</span>
|
||||
{/* En-tête de page */}
|
||||
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Toast Copié */}
|
||||
{copiedToast && (
|
||||
<div className="maj-floating-toast">
|
||||
✓ Copié dans le presse-papier !
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
<div className="maj-body">
|
||||
{/* 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 & Déploiement</h3>
|
||||
</div>
|
||||
|
||||
{/* État 1 : Idle (En attente) */}
|
||||
{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 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>
|
||||
{checkError && (
|
||||
<div className="maj-inline-error">
|
||||
<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-actions">
|
||||
<wcs-spinner></wcs-spinner>
|
||||
<p>Recherche de mises à jour sur le serveur Git distant...</p>
|
||||
<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 & diff)...
|
||||
</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>
|
||||
{/* É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>Mise à jour prête : {newVersion}</strong>
|
||||
<p>Cette action va exécuter `git pull`, puis recompiler l'application. Le panel sera temporairement indisponible.</p>
|
||||
<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>
|
||||
<wcs-button onClick={applyUpdate} className="wcs-primary">Lancer la mise à jour (PM2)</wcs-button>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Options d'exécution */}
|
||||
<div className="maj-options-box">
|
||||
<label className="maj-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* État 5 : Updating (Exécution du pipeline) */}
|
||||
{status === 'updating' && (
|
||||
<div className="maj-actions" style={{ maxWidth: '600px', width: '100%' }}>
|
||||
<p>Mise à jour en cours sur le serveur...</p>
|
||||
<div className="progress-container">
|
||||
<wcs-progress-bar value={progress}></wcs-progress-bar>
|
||||
<span className="progress-text">{progress}%</span>
|
||||
<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>
|
||||
<p className="progress-details" style={{ fontWeight: 'bold', margin: '10px 0' }}>
|
||||
Étape : {stepName || "Initialisation..."}
|
||||
</p>
|
||||
<div className="terminal-logs" style={{ background: '#1e1e1e', color: '#e0e0e0', padding: '10px', borderRadius: '6px', height: '150px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '12px', textAlign: 'left' }}>
|
||||
{logs.length === 0 ? "En attente des logs..." : logs.map((log, i) => (
|
||||
<div key={i}>{log}</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>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="maj-actions error">
|
||||
<wcs-mat-icon icon="error" style={{ color: '#dc2626', fontSize: '48px' }}></wcs-mat-icon>
|
||||
<p style={{ color: '#dc2626', fontWeight: 'bold' }}>Une erreur critique est survenue durant la mise à jour.</p>
|
||||
<p>Consultez les logs du serveur (PM2) ou vérifiez les permissions Git.</p>
|
||||
<wcs-button onClick={() => setStatus('idle')} className="wcs-secondary">Retour</wcs-button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* État 6 : Success (Succès) */}
|
||||
{status === 'success' && (
|
||||
<div className="maj-actions success">
|
||||
<wcs-mat-icon icon="check_circle" style={{ color: '#16a34a', fontSize: '48px' }}></wcs-mat-icon>
|
||||
<p>Mise à jour compilée et déployée avec succès !</p>
|
||||
<p>L'application va se recharger automatiquement pour appliquer les changements...</p>
|
||||
<div className="maj-state-box">
|
||||
<div className="maj-alert-success large">
|
||||
<wcs-mat-icon icon="verified" style={{ color: '#16a34a', fontSize: '48px' }}></wcs-mat-icon>
|
||||
<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 & 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>
|
||||
|
||||
@@ -55,6 +55,102 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
|
||||
+281
-9
@@ -3,19 +3,42 @@ import NavPanel from '../../components/nav-panel/NavPanel';
|
||||
import TarifForm from './TarifForm.js';
|
||||
import TarifList from './TarifList.js';
|
||||
import { getAllTarifs, deleteTarif } from '../../api/tarifs';
|
||||
import { getParametre, upsertParametre } from '../../api/parametres';
|
||||
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() {
|
||||
const currentUser = getCurrentUser();
|
||||
const canCreateTarif = canPerform(currentUser, 'tarifs', 'create');
|
||||
const canUpdateTarif = canPerform(currentUser, 'tarifs', 'update');
|
||||
const canDeleteTarif = canPerform(currentUser, 'tarifs', 'delete');
|
||||
const canManagePaliers = canUpdateTarif;
|
||||
const [tarifs, setTarifs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editing, setEditing] = useState(null); // tarif en édition
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -42,6 +65,131 @@ export default function Tarifs() {
|
||||
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) => {
|
||||
if (!canDeleteTarif) {
|
||||
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>}
|
||||
</div>
|
||||
<div className="tarifs-header-actions">
|
||||
<wcs-button className="wcs-primary" onClick={handleCreateNew} disabled={!canCreateTarif}>
|
||||
<wcs-mat-icon icon="add" slot="prefix"></wcs-mat-icon>
|
||||
Nouveau tarif
|
||||
</wcs-button>
|
||||
{activeTab === 'tarifs' && (
|
||||
<wcs-button className="wcs-primary" onClick={handleCreateNew} disabled={!canCreateTarif}>
|
||||
<wcs-mat-icon icon="add" slot="prefix"></wcs-mat-icon>
|
||||
Nouveau tarif
|
||||
</wcs-button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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 */}
|
||||
{showModal && editing && (
|
||||
{activeTab === 'tarifs' && showModal && editing && (
|
||||
<div className="modal-overlay" onClick={() => { setShowModal(false); setEditing(null); }}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 800 }}>
|
||||
<div className="modal-header">
|
||||
@@ -131,12 +297,12 @@ export default function Tarifs() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
{activeTab === 'tarifs' && loading ? (
|
||||
<div className="loading-state-full tarifs-state-block">
|
||||
<wcs-spinner mode="border" size="l"></wcs-spinner>
|
||||
<p>Récupération des tarifs...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
) : activeTab === 'tarifs' && error ? (
|
||||
<div className="error-message-container tarifs-state-block">
|
||||
<wcs-message mode="danger">
|
||||
<wcs-mat-icon icon="error_outline" slot="prefix"></wcs-mat-icon>
|
||||
@@ -144,8 +310,115 @@ export default function Tarifs() {
|
||||
</wcs-message>
|
||||
<wcs-button mode="stroked" className="wcs-primary" onClick={loadTarifs}>Réessayer</wcs-button>
|
||||
</div>
|
||||
) : (
|
||||
) : activeTab === 'tarifs' ? (
|
||||
<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>
|
||||
@@ -154,4 +427,3 @@ export default function Tarifs() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ export const RBAC_MODULES = [
|
||||
'gares',
|
||||
'materiel_roulant',
|
||||
'horaires',
|
||||
'fiches_horaires',
|
||||
'lignes',
|
||||
'services',
|
||||
'quais',
|
||||
@@ -42,6 +43,7 @@ export const ROLE_PERMISSION_TEMPLATES = {
|
||||
gares: fullAccess(),
|
||||
materiel_roulant: fullAccess(),
|
||||
horaires: fullAccess(),
|
||||
fiches_horaires: fullAccess(),
|
||||
lignes: fullAccess(),
|
||||
services: fullAccess(),
|
||||
quais: fullAccess(),
|
||||
@@ -61,6 +63,7 @@ export const ROLE_PERMISSION_TEMPLATES = {
|
||||
gares: fullAccess(),
|
||||
materiel_roulant: fullAccess(),
|
||||
horaires: fullAccess(),
|
||||
fiches_horaires: fullAccess(),
|
||||
lignes: fullAccess(),
|
||||
services: fullAccess(),
|
||||
quais: fullAccess(),
|
||||
|
||||
+401
-48
@@ -1,105 +1,458 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { spawn } = require('child_process');
|
||||
/**
|
||||
* Ferrovia Panel - Service de Mise à Jour Automatique & Gestionnaire Système
|
||||
*
|
||||
* 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 fs = require('fs');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
const PORT = parseInt(process.env.UPDATER_PORT || process.env.PORT || '4001', 10);
|
||||
const APP_DIR = path.resolve(__dirname);
|
||||
|
||||
const PORT = process.env.UPDATER_PORT || 4001;
|
||||
|
||||
// State to keep track of the current update process
|
||||
// État global du processus de mise à jour
|
||||
let updateState = {
|
||||
status: 'idle', // idle, updating, success, error
|
||||
status: 'idle', // 'idle' | 'checking' | 'updating' | 'success' | 'error'
|
||||
progress: 0,
|
||||
step: '',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
logs: []
|
||||
};
|
||||
|
||||
function addLog(msg) {
|
||||
console.log(msg);
|
||||
updateState.logs.push(msg);
|
||||
if (updateState.logs.length > 100) {
|
||||
updateState.logs.shift(); // Keep last 100 logs
|
||||
/**
|
||||
* Ajout d'une ligne de journal horodatée avec typage
|
||||
*/
|
||||
function addLog(text, type = 'info') {
|
||||
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) => {
|
||||
updateState.step = stepName;
|
||||
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) => {
|
||||
addLog(data.toString().trim());
|
||||
const lines = data.toString().split('\n').filter(Boolean);
|
||||
lines.forEach(line => addLog(line, 'stdout'));
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (code !== 0) {
|
||||
addLog(`>>> Échec avec le code ${code}`);
|
||||
reject(new Error(`La commande ${command} a échoué.`));
|
||||
const errMsg = `La commande "${command} ${args.join(' ')}" a échoué avec le code de sortie ${code}`;
|
||||
addLog(`>>> ${errMsg}`, 'error');
|
||||
reject(new Error(errMsg));
|
||||
} else {
|
||||
updateState.progress = progressEnd;
|
||||
addLog(`>>> Succès: ${stepName}`);
|
||||
addLog(`>>> Étape réussie : ${stepName}`, 'success');
|
||||
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') {
|
||||
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 = {
|
||||
status: 'updating',
|
||||
progress: 10,
|
||||
step: 'Initialisation',
|
||||
progress: 5,
|
||||
step: 'Initialisation de la mise à jour',
|
||||
error: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
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 {
|
||||
// Step 1: Git Pull (10% to 30%)
|
||||
await runCommand('git', ['pull'], 'Téléchargement des sources (git pull)', 10, 30);
|
||||
// Étape 1 : Récupération Git (5% -> 35%)
|
||||
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('npm', ['install'], 'Installation des dépendances (npm install)', 30, 60);
|
||||
await runCommand('git', ['fetch', 'origin'], 'Synchronisation des métadonnées Git', 10, 20);
|
||||
|
||||
// Step 3: Build (60% to 100%)
|
||||
await runCommand('npm', ['run', 'build'], 'Compilation de l\'application (npm run build)', 60, 100);
|
||||
// Si modifications locales non commitées, faire un stash temporaire sécurisé
|
||||
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.step = 'Terminé';
|
||||
updateState.step = 'Mise à jour terminée avec succès';
|
||||
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.step = 'Erreur';
|
||||
addLog(`>>> ERREUR CRITIQUE: ${error.message}`);
|
||||
updateState.step = 'Échec de la mise à jour';
|
||||
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
|
||||
app.post('/api/update/reset', (req, res) => {
|
||||
updateState = { status: 'idle', progress: 0, step: '', logs: [] };
|
||||
res.json({ message: 'Reset OK' });
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`====================================================`);
|
||||
console.log(`🚆 FERROVIA PANEL - UPDATER DAEMON DÉMARRÉ`);
|
||||
console.log(`📡 Port d'écoute : http://localhost:${PORT}`);
|
||||
console.log(`📁 Répertoire : ${APP_DIR}`);
|
||||
console.log(`====================================================`);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[Ferrovia Updater] Écoute sur le port ${PORT}`);
|
||||
// Arrêt propre
|
||||
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));
|
||||
});
|
||||
Reference in new issue
Block a user