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