V5.0.0 //

-> Refonte du système de GESTION DES ROULEMENTS de matériels roulants
-> Optimisation rendue BDD
This commit is contained in:
AdminGit committed 2026-10-03 17:33:53 +02:00
1 parent 36b0c00cbc
commit f77d59ca1c
25 files changed
+16567 -6198

No files matched your search

+5 -1
View File
@@ -1,2 +1,6 @@
NEXT_PUBLIC_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_6dB95QNDKiHcFD81TJETqq_3Mu5rWWU
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_k8EI4S2YA_6Hl5av8uCBr3_S1Bj5g_F
REACT_APP_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr
REACT_APP_SUPABASE_ANON_KEY=sb_publishable_k8EI4S2YA_6Hl5av8uCBr3_S1Bj5g_F
REACT_APP_SUPABASE_PUBLISHABLE_KEY=sb_publishable_k8EI4S2YA_6Hl5av8uCBr3_S1Bj5g_F
+5 -1
View File
@@ -1,2 +1,6 @@
NEXT_PUBLIC_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_6dB95QNDKiHcFD81TJETqq_3Mu5rWWU
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_k8EI4S2YA_6Hl5av8uCBr3_S1Bj5g_F
REACT_APP_SUPABASE_URL=https://supabase-ferrovia.mrpatator.fr
REACT_APP_SUPABASE_ANON_KEY=sb_publishable_k8EI4S2YA_6Hl5av8uCBr3_S1Bj5g_F
REACT_APP_SUPABASE_PUBLISHABLE_KEY=sb_publishable_k8EI4S2YA_6Hl5av8uCBr3_S1Bj5g_F
+1607
View File
File diff suppressed because it is too large. Load diff
+1737 -1676
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -4,6 +4,7 @@
"private": true,
"dependencies": {
"@sncf/bootstrap-sncf.communication": "^4.3.1-r5",
"@sncf/bootstrap-sncf.metier": "^4.3.1-r5",
"@supabase/supabase-js": "^2.112.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
+1 -1
View File
@@ -1,5 +1,5 @@
@import "wcs-core/dist/wcs/wcs.css";
@import "@sncf/bootstrap-sncf.communication/dist/bootstrap-sncf.css";
@import "@sncf/bootstrap-sncf.metier/dist/bootstrap-sncf.min.css";
/* styles.scss des différents labels SNCF */
@import "wcs-core/design-tokens/dist/sncf-voyageurs.css";
+19 -4
View File
@@ -35,7 +35,13 @@ function App() {
}
// Synchronisation de session Supabase Auth au démarrage
supabase.auth.getSession().then(({ data: { session } }) => {
supabase.auth.getSession().then(({ data: { session }, error }) => {
if (error) {
console.warn('[App] Session Supabase invalide:', error);
window.localStorage.removeItem('app_user');
setCurrentUser(null);
return;
}
if (session?.user && !window.location.pathname.startsWith('/reinitialiser-mot-de-passe')) {
const stored = window.localStorage.getItem('app_user');
if (!stored) {
@@ -46,7 +52,11 @@ function App() {
}
}
}
}).catch(() => {});
}).catch((err) => {
console.warn('[App] Erreur lors de la récupération de session:', err);
window.localStorage.removeItem('app_user');
setCurrentUser(null);
});
// écouter un événement custom pour synchroniser l'état d'authentification
const onAuthChanged = () => {
@@ -61,8 +71,13 @@ function App() {
const onUnhandledRejection = (event) => {
const reason = event?.reason;
const message = reason?.message || '';
if (reason?.name === 'AbortError' || message === 'The operation was aborted.') {
// Certaines annulations navigateur/FileReader ne doivent pas afficher l'overlay runtime.
if (
reason?.name === 'AbortError' ||
message === 'The operation was aborted.' ||
reason?.name === 'AuthRetryableFetchError' ||
message.includes('Unexpected failure, please check server logs')
) {
// Certaines annulations navigateur ou retries réseau ne doivent pas bloquer l'UI
event.preventDefault();
}
};
+138
View File
@@ -0,0 +1,138 @@
import supabase, { isSupabaseConfigured } from '../supabaseClient';
/**
* Récupère la liste complète des dépôts et technicentres enregistrés directement dans la base de données (table depots_technicentres).
*/
export async function getAllDepots() {
if (!isSupabaseConfigured) {
return { data: [], error: { message: 'Supabase non configuré' } };
}
try {
const { data, error } = await supabase
.from('depots_technicentres')
.select('*')
.is('deleted_at', null)
.order('nom', { ascending: true });
if (error) {
console.error('Erreur chargement depots_technicentres depuis la BDD:', error);
return { data: [], error };
}
return { data: data || [], error: null };
} catch (err) {
console.error('Erreur API getAllDepots:', err);
return { data: [], error: err };
}
}
/**
* Récupère un dépôt / technicentre par son identifiant UUID dans la BDD.
*/
export async function getDepotById(id) {
if (!isSupabaseConfigured) return { data: null, error: { message: 'Supabase non configuré' } };
try {
const { data, error } = await supabase
.from('depots_technicentres')
.select('*')
.eq('id', id)
.single();
return { data, error };
} catch (err) {
return { data: null, error: err };
}
}
/**
* Crée un nouveau dépôt ou technicentre de maintenance dans Supabase (table depots_technicentres).
*/
export async function createDepot(payload) {
if (!isSupabaseConfigured) return { data: payload, error: null };
try {
const cleanPayload = {
...payload,
capacite_max: Number(payload.capacite_max || 20),
specialites: Array.isArray(payload.specialites)
? payload.specialites
: (typeof payload.specialites === 'string'
? payload.specialites.split(',').map(s => s.trim()).filter(Boolean)
: []),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
const { data, error } = await supabase
.from('depots_technicentres')
.insert([cleanPayload])
.select('*')
.single();
return { data, error };
} catch (err) {
return { data: null, error: err };
}
}
/**
* Met à jour un dépôt / technicentre existant dans Supabase.
*/
export async function updateDepot(id, payload) {
if (!isSupabaseConfigured) return { data: { id, ...payload }, error: null };
try {
const cleanPayload = {
...payload,
capacite_max: payload.capacite_max != null ? Number(payload.capacite_max) : undefined,
specialites: payload.specialites !== undefined
? (Array.isArray(payload.specialites)
? payload.specialites
: typeof payload.specialites === 'string'
? payload.specialites.split(',').map(s => s.trim()).filter(Boolean)
: [])
: undefined,
updated_at: new Date().toISOString()
};
Object.keys(cleanPayload).forEach(key => cleanPayload[key] === undefined && delete cleanPayload[key]);
const { data, error } = await supabase
.from('depots_technicentres')
.update(cleanPayload)
.eq('id', id)
.select('*')
.single();
return { data, error };
} catch (err) {
return { data: null, error: err };
}
}
/**
* Supprime un dépôt / technicentre de la BDD (table depots_technicentres).
* Détache d'abord les matériels roulants assignés pour éviter toute violation de clé étrangère.
*/
export async function deleteDepot(id) {
if (!isSupabaseConfigured) return { error: null };
try {
// 1. Détacher les matériels roulants assignés à ce dépôt
try {
await supabase
.from('materiel-roulant')
.update({ depot_id: null, depot_attache: null })
.eq('depot_id', id);
} catch (e) {
console.warn('Avertissement détachement matériel:', e);
}
// 2. Supprimer la ligne de la table depots_technicentres
const { error } = await supabase
.from('depots_technicentres')
.delete()
.eq('id', id);
return { error };
} catch (err) {
return { error: err };
}
}
+91
View File
@@ -518,6 +518,8 @@ export async function updateHoraireComposition(id, compositionTrain = []) {
materiel_nom: item?.materiel_nom || '',
materiel_nom_technique: item?.materiel_nom_technique || '',
materiel_image_url: item?.materiel_image_url || null,
capacite: item?.capacite != null ? Number(item.capacite) : null,
type_train: item?.type_train || '',
position: idx + 1
}));
@@ -535,6 +537,29 @@ export async function updateHoraireComposition(id, compositionTrain = []) {
}
}
/**
* Met à jour par lots les compositions de trains optimisées.
* @param {Array<{horaireId: string|number, composition: Array}>} updates
*/
export async function updateHorairesCompositionsBatch(updates = []) {
if (!isSupabaseConfigured) return { data: [], error: null };
try {
const results = [];
const CHUNK_SIZE = 5;
for (let i = 0; i < updates.length; i += CHUNK_SIZE) {
const chunk = updates.slice(i, i + CHUNK_SIZE);
await Promise.all(chunk.map(async (u) => {
const { data, error } = await updateHoraireComposition(u.horaireId, u.composition);
results.push({ horaireId: u.horaireId, data, error });
}));
}
return { data: results, error: null };
} catch (err) {
console.error('Exception updateHorairesCompositionsBatch:', err);
return { data: null, error: err };
}
}
/**
* Supprime un horaire (hard delete)
* @param {number|string} id - ID de l'horaire
@@ -1162,3 +1187,69 @@ export async function reassignGareInHoraires(sourceId, targetId, targetNom) {
return { error: err };
}
}
/**
* Couple deux circulations horaires en unité multiple synchronisée (tranche commune)
* ou découple un train existant.
*/
export async function updateHoraireCouplage(sourceId, sourceNumeroTrain, targetId, targetNumeroTrain) {
if (!isSupabaseConfigured) return { error: null };
try {
if (targetId) {
// 1. Coupler la source avec la cible
await supabase.from('horaires').update({
couplage_train_id: targetId,
couplage_numero_train: targetNumeroTrain,
updated_at: new Date()
}).eq('id', sourceId);
// 2. Coupler réciproquement la cible avec la source
await supabase.from('horaires').update({
couplage_train_id: sourceId,
couplage_numero_train: sourceNumeroTrain,
updated_at: new Date()
}).eq('id', targetId);
} else {
// Découplage
const { data: current } = await supabase.from('horaires').select('couplage_train_id').eq('id', sourceId).single();
const oldTargetId = current?.couplage_train_id;
await supabase.from('horaires').update({
couplage_train_id: null,
couplage_numero_train: null,
updated_at: new Date()
}).eq('id', sourceId);
if (oldTargetId) {
await supabase.from('horaires').update({
couplage_train_id: null,
couplage_numero_train: null,
updated_at: new Date()
}).eq('id', oldTargetId);
}
}
return { error: null };
} catch (err) {
console.error('Exception updateHoraireCouplage:', err);
return { error: err };
}
}
/**
* Enregistre les manœuvres ferroviaires d'une circulation (mise à quai, garage, attelage).
*/
export async function updateHoraireManoeuvres(horaireId, manoeuvres = []) {
if (!isSupabaseConfigured) return { error: null };
try {
const { data, error } = await supabase
.from('horaires')
.update({ manoeuvres, updated_at: new Date() })
.eq('id', horaireId)
.select('manoeuvres')
.single();
return { data: data?.manoeuvres || manoeuvres, error };
} catch (err) {
console.error('Exception updateHoraireManoeuvres:', err);
return { data: null, error: err };
}
}
+107 -46
View File
@@ -143,32 +143,61 @@ export async function getPerturbationById(id) {
/**
* Crée une nouvelle perturbation
* @param {Object} payload - Données de la perturbation
* @param {Object} payload - Données de la perturbation (ou objet { payload, modificationsParcours })
* @param {Array} modificationsParcours - Liste des modifications de parcours (optionnel)
*/
export async function createPerturbation(payload, modificationsParcours = []) {
if (!isSupabaseConfigured) {
const fakeId = `local-${Date.now()}`;
return { data: { id: fakeId, ...payload }, error: null };
return { data: { id: fakeId, ...(payload?.payload || payload) }, error: null };
}
try {
// Supporter à la fois un payload plat ou l'objet composite retourné par buildPayload ({ payload, modificationsParcours })
let actualPayload = payload;
let modifs = Array.isArray(modificationsParcours) ? [...modificationsParcours] : [];
if (payload && typeof payload === 'object' && payload.payload) {
if (modifs.length === 0 && Array.isArray(payload.modificationsParcours)) {
modifs = payload.modificationsParcours;
}
actualPayload = payload.payload;
} else if (payload && Array.isArray(payload.modificationsParcours) && modifs.length === 0) {
modifs = payload.modificationsParcours;
}
// Extraire jours_perturbation et le stocker dans perturbations_arrets (colonne JSONB existante)
const { jours_perturbation, perturbations_arrets, ...rest } = payload;
// Et filtrer strictement les clés qui n'appartiennent pas à la table perturbations
const {
jours_perturbation,
perturbations_arrets,
modificationsParcours: _ignore1,
modifications_parcours: _ignore2,
payload: _ignore3,
scopedRegionId: _ignore4,
...rest
} = actualPayload || {};
const perturbationsArretsData = {
arrets: perturbations_arrets || [],
jours_perturbation: jours_perturbation || null
};
const cleanInsert = {
...rest,
perturbations_arrets: perturbationsArretsData,
created_at: new Date(),
updated_at: new Date()
};
delete cleanInsert.modificationsParcours;
delete cleanInsert.modifications_parcours;
delete cleanInsert.payload;
delete cleanInsert.scopedRegionId;
// Créer la perturbation principale
const { data: perturbation, error: perturbationError } = await supabase
.from('perturbations')
.insert([{
...rest,
perturbations_arrets: perturbationsArretsData,
created_at: new Date(),
updated_at: new Date()
}])
.insert([cleanInsert])
.select('*')
.single();
@@ -178,19 +207,22 @@ export async function createPerturbation(payload, modificationsParcours = []) {
}
// Insérer les modifications de parcours si présentes
if (modificationsParcours.length > 0 && perturbation) {
const modificationsData = modificationsParcours.map(modif => ({
perturbation_id: perturbation.id,
...modif
}));
if (modifs && modifs.length > 0 && perturbation) {
try {
const modificationsData = modifs.map(modif => ({
perturbation_id: perturbation.id,
...modif
}));
const { error: modifError } = await supabase
.from('perturbations_modifications_parcours')
.insert(modificationsData);
const { error: modifError } = await supabase
.from('perturbations_modifications_parcours')
.insert(modificationsData);
if (modifError) {
console.error('Erreur lors de l\'insertion des modifications de parcours:', modifError);
// On continue même si erreur car la perturbation principale est créée
if (modifError) {
console.warn('Note: insertion perturbations_modifications_parcours non bloquante:', modifError.message || modifError);
}
} catch (errModif) {
console.warn('Note: table perturbations_modifications_parcours non disponible:', errModif.message || errModif);
}
}
@@ -204,30 +236,57 @@ export async function createPerturbation(payload, modificationsParcours = []) {
/**
* Met à jour une perturbation existante
* @param {string} id - ID de la perturbation
* @param {Object} payload - Données à mettre à jour
* @param {Object} payload - Données à mettre à jour (ou objet { payload, modificationsParcours })
* @param {Array} modificationsParcours - Nouvelle liste des modifications de parcours (remplace l'existante)
*/
export async function updatePerturbation(id, payload, modificationsParcours = null) {
if (!isSupabaseConfigured) {
return { data: { id, ...payload }, error: null };
return { data: { id, ...(payload?.payload || payload) }, error: null };
}
try {
let actualPayload = payload;
let modifs = modificationsParcours;
if (payload && typeof payload === 'object' && payload.payload) {
if (modifs === null && Array.isArray(payload.modificationsParcours)) {
modifs = payload.modificationsParcours;
}
actualPayload = payload.payload;
} else if (payload && Array.isArray(payload.modificationsParcours) && modifs === null) {
modifs = payload.modificationsParcours;
}
// Extraire jours_perturbation et le stocker dans perturbations_arrets (colonne JSONB existante)
const { jours_perturbation, perturbations_arrets, ...rest } = payload;
const {
jours_perturbation,
perturbations_arrets,
modificationsParcours: _ignore1,
modifications_parcours: _ignore2,
payload: _ignore3,
scopedRegionId: _ignore4,
...rest
} = actualPayload || {};
const perturbationsArretsData = {
arrets: perturbations_arrets || [],
jours_perturbation: jours_perturbation || null
};
const cleanUpdate = {
...rest,
perturbations_arrets: perturbationsArretsData,
updated_at: new Date()
};
delete cleanUpdate.modificationsParcours;
delete cleanUpdate.modifications_parcours;
delete cleanUpdate.payload;
delete cleanUpdate.scopedRegionId;
// Mettre à jour la perturbation principale
const { data: perturbation, error: perturbationError } = await supabase
.from('perturbations')
.update({
...rest,
perturbations_arrets: perturbationsArretsData,
updated_at: new Date()
})
.update(cleanUpdate)
.eq('id', id)
.select('*')
.single();
@@ -238,27 +297,29 @@ export async function updatePerturbation(id, payload, modificationsParcours = nu
}
// Si modifications de parcours fournies, supprimer les anciennes et insérer les nouvelles
if (modificationsParcours !== null && Array.isArray(modificationsParcours)) {
// Supprimer les anciennes modifications
await supabase
.from('perturbations_modifications_parcours')
.delete()
.eq('perturbation_id', id);
// Insérer les nouvelles modifications
if (modificationsParcours.length > 0) {
const modificationsData = modificationsParcours.map(modif => ({
perturbation_id: id,
...modif
}));
const { error: modifError } = await supabase
if (modifs !== null && Array.isArray(modifs)) {
try {
await supabase
.from('perturbations_modifications_parcours')
.insert(modificationsData);
.delete()
.eq('perturbation_id', id);
if (modifError) {
console.error('Erreur lors de la mise à jour des modifications de parcours:', modifError);
if (modifs.length > 0) {
const modificationsData = modifs.map(modif => ({
perturbation_id: id,
...modif
}));
const { error: modifError } = await supabase
.from('perturbations_modifications_parcours')
.insert(modificationsData);
if (modifError) {
console.warn('Note: mise à jour perturbations_modifications_parcours non bloquante:', modifError.message || modifError);
}
}
} catch (errModif) {
console.warn('Note: table perturbations_modifications_parcours non disponible:', errModif.message || errModif);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+17 -3
View File
@@ -55,8 +55,13 @@ export default function Login({ onLogin }) {
});
if (authError || !authData.user) {
const msg = authError?.message || 'Identifiants invalides';
setError(`Erreur d'authentification: ${msg}`);
let msg = authError?.message || 'Identifiants invalides';
if (msg.includes('Invalid login credentials') || authError?.status === 400) {
msg = 'Identifiant ou mot de passe incorrect. Veuillez vérifier votre saisie.';
} else if (msg.includes('Email not confirmed')) {
msg = 'Votre compte n\'a pas encore été activé. Veuillez utiliser le lien d\'activation reçu par email.';
}
setError(msg);
setLoading(false);
return;
}
@@ -156,8 +161,9 @@ export default function Login({ onLogin }) {
<wcs-alert
ref={alertRef}
intent="error"
show
closable
timeout={4000}
timeout={5000}
>
<span slot="title">Erreur</span>
<span slot="subtitle">{error}</span>
@@ -173,6 +179,14 @@ export default function Login({ onLogin }) {
emailRef.current && (emailRef.current.value = '');
passRef.current && (passRef.current.value = '');
setError('');
try {
Object.keys(window.localStorage).forEach(k => {
if (k.startsWith('sb-') || k.includes('supabase')) {
window.localStorage.removeItem(k);
}
});
supabase.auth.signOut({ scope: 'local' }).catch(() => {});
} catch (_) {}
}}>
Reinitialiser
</wcs-button>
+71 -9
View File
@@ -372,14 +372,19 @@
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
overflow: visible;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04);
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.materiel-card-modern.has-open-menu {
z-index: 60;
}
.materiel-card-modern:hover {
transform: translateY(-3px);
border-color: #cbd5e1;
@@ -395,6 +400,8 @@
justify-content: flex-start;
overflow: hidden;
position: relative;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
border-bottom: 1px solid #e2e8f0;
}
@@ -488,40 +495,55 @@
}
.materiel-actions-toggle {
min-width: 32px;
width: 32px;
height: 32px;
min-width: 32px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #ffffff;
color: #475569;
cursor: pointer;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
outline: none;
padding: 0;
}
.materiel-actions-toggle:hover {
.materiel-actions-toggle:hover,
.materiel-actions-toggle.active {
background: #f1f5f9;
color: #0f172a;
border-color: #0088ce;
color: #0088ce;
box-shadow: 0 0 0 2px rgba(0, 136, 206, 0.15);
}
.materiel-actions-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 50;
width: 140px;
z-index: 100;
min-width: 144px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
box-shadow: 0 10px 25px -5px rgba(15, 23, 42, 0.2), 0 8px 10px -6px rgba(15, 23, 42, 0.1);
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.materiel-actions-menu.open-up {
bottom: calc(100% + 6px);
top: auto;
}
.materiel-actions-menu.open-down {
top: calc(100% + 6px);
bottom: auto;
}
.materiel-actions-menu button {
border: none;
background: transparent;
@@ -538,6 +560,11 @@
transition: all 0.15s ease;
}
.materiel-actions-menu button:hover {
background: #f1f5f9;
color: #0088ce;
}
.materiel-actions-menu button:hover {
background: #f1f5f9;
color: #0f172a;
@@ -1033,3 +1060,38 @@ body.dark-mode .materiels-empty-text,
color: #94a3b8;
}
body.dark-mode .materiel-actions-menu,
[data-theme="dark"] .materiel-actions-menu {
background: #1e293b;
border-color: #334155;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
}
body.dark-mode .materiel-actions-menu button,
[data-theme="dark"] .materiel-actions-menu button {
color: #f1f5f9;
}
body.dark-mode .materiel-actions-menu button:hover,
[data-theme="dark"] .materiel-actions-menu button:hover {
background: #334155;
color: #38bdf8;
}
body.dark-mode .materiel-actions-toggle,
[data-theme="dark"] .materiel-actions-toggle {
background: #1e293b;
border-color: #334155;
color: #cbd5e1;
}
body.dark-mode .materiel-actions-toggle:hover,
body.dark-mode .materiel-actions-toggle.active,
[data-theme="dark"] .materiel-actions-toggle:hover,
[data-theme="dark"] .materiel-actions-toggle.active {
background: #27354a;
border-color: #38bdf8;
color: #38bdf8;
}
+43 -37
View File
@@ -73,7 +73,7 @@ function MaterielGroupCard({ imageUrl, items, onEdit, onDelete, onDuplicate, can
};
return (
<div className="materiel-card-modern">
<div className={`materiel-card-modern ${openActionsFor ? 'has-open-menu' : ''}`}>
<div className="materiel-card-media" ref={imgContainerRef}>
{image ? (
<img
@@ -103,43 +103,49 @@ function MaterielGroupCard({ imageUrl, items, onEdit, onDelete, onDuplicate, can
</div>
<div className={`materiel-group-list ${items.length > 2 ? 'scrollable' : ''}`}>
{items.map((materiel) => (
<div key={materiel.id} className="materiel-group-row">
<div className="materiel-group-main">
<span className="materiel-serial-pill">Rame n° {materiel.numero_serie || '-'}</span>
<span className="materiel-desc-text">
{materiel.nom || '-'} · {materiel.nom_technique || '-'}
{materiel.capacite ? ` (${materiel.capacite} pl.)` : ''}
</span>
{items.map((materiel, idx) => {
const isLast = idx === items.length - 1 || items.length <= 2;
return (
<div key={materiel.id} className="materiel-group-row">
<div className="materiel-group-main">
<span className="materiel-serial-pill">Rame n° {materiel.numero_serie || '-'}</span>
<span className="materiel-desc-text">
{materiel.nom || '-'} · {materiel.nom_technique || '-'}
{materiel.capacite ? ` (${materiel.capacite} pl.)` : ''}
</span>
</div>
<div className="materiel-row-actions">
<button
type="button"
className={`materiel-actions-toggle ${openActionsFor === materiel.id ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
setOpenActionsFor((prev) => (prev === materiel.id ? null : materiel.id));
}}
aria-label="Ouvrir les actions"
>
<wcs-mat-icon icon="more_vert" size="s"></wcs-mat-icon>
</button>
{openActionsFor === materiel.id && (
<div className={`materiel-actions-menu ${isLast ? 'open-up' : 'open-down'}`}>
<button type="button" onClick={() => { setOpenActionsFor(null); onEdit(materiel); }} disabled={!canUpdate}>
<wcs-mat-icon icon="edit" size="s"></wcs-mat-icon>
Modifier
</button>
<button type="button" onClick={() => { setOpenActionsFor(null); onDuplicate(materiel); }} disabled={!canDuplicate}>
<wcs-mat-icon icon="content_copy" size="s"></wcs-mat-icon>
Dupliquer
</button>
<button type="button" className="danger" onClick={() => { setOpenActionsFor(null); onDelete(materiel.id); }} disabled={!canDelete}>
<wcs-mat-icon icon="delete" size="s"></wcs-mat-icon>
Supprimer
</button>
</div>
)}
</div>
</div>
<div className="materiel-row-actions" ref={actionsRef}>
<button
type="button"
className="materiel-actions-toggle"
onClick={() => setOpenActionsFor((prev) => (prev === materiel.id ? null : materiel.id))}
aria-label="Ouvrir les actions"
>
<wcs-mat-icon icon="more_vert" size="s"></wcs-mat-icon>
</button>
{openActionsFor === materiel.id && (
<div className="materiel-actions-menu">
<button type="button" onClick={() => { setOpenActionsFor(null); onEdit(materiel); }} disabled={!canUpdate}>
<wcs-mat-icon icon="edit" size="s"></wcs-mat-icon>
Modifier
</button>
<button type="button" onClick={() => { setOpenActionsFor(null); onDuplicate(materiel); }} disabled={!canDuplicate}>
<wcs-mat-icon icon="content_copy" size="s"></wcs-mat-icon>
Dupliquer
</button>
<button type="button" className="danger" onClick={() => { setOpenActionsFor(null); onDelete(materiel.id); }} disabled={!canDelete}>
<wcs-mat-icon icon="delete" size="s"></wcs-mat-icon>
Supprimer
</button>
</div>
)}
</div>
</div>
))}
);
})}
</div>
</div>
);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+393 -274
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import NavPanel from '../../components/nav-panel/NavPanel';
import { createTravaux, getHorairesSubstitution } from '../../api/travaux';
import { getAllLignes } from '../../api/lignes';
@@ -21,6 +21,9 @@ export default function CreerTravaux() {
const currentUser = getCurrentUser();
const canCreateTravaux = canPerform(currentUser, 'travaux', 'create');
const [activeTabKey, setActiveTabKey] = useState('general');
const tabsRef = useRef(null);
const [titre, setTitre] = useState('');
const [description, setDescription] = useState('');
const [dateDebut, setDateDebut] = useState('');
@@ -62,6 +65,21 @@ export default function CreerTravaux() {
fetchLignes();
}, [scopedRegionId]);
// Synchronisation des wcs-tabs
useEffect(() => {
const el = tabsRef.current;
if (!el) return;
const handleTabChange = (e) => {
if (e.detail?.selectedKey) {
setActiveTabKey(e.detail.selectedKey);
}
};
el.addEventListener('wcsChange', handleTabChange);
return () => {
el.removeEventListener('wcsChange', handleTabChange);
};
}, []);
const handleSearchHoraires = useCallback(async () => {
setLoadingHoraires(true);
setShowResults(true);
@@ -108,7 +126,7 @@ export default function CreerTravaux() {
setJoursTravaux(['samedi', 'dimanche']);
};
const formatHeure = (heure) => heure ? heure.substring(0, 5) : '—';
const formatHeure = (heure) => (heure ? heure.substring(0, 5) : '—');
const navigate = (path) => { window.location.href = path; };
@@ -163,20 +181,33 @@ export default function CreerTravaux() {
<div className="panel-nav">
<NavPanel />
</div>
<div className="panel-content travaux-form-page-content">
{/* Header Banner */}
{/* Header Banner SNCF avec composants WCS */}
<header className="travaux-form-header-modern">
<div className="form-header-main">
<button type="button" className="btn-back-link" onClick={() => navigate('/travaux')}>
<wcs-mat-icon icon="arrow_back"></wcs-mat-icon>
<wcs-button
mode="stroked"
size="s"
className="wcs-secondary mb-2"
onClick={() => navigate('/travaux')}
>
<wcs-mat-icon icon="arrow_back" size="s" class="mr-1"></wcs-mat-icon>
<span>Retour aux travaux</span>
</button>
</wcs-button>
<div className="form-title-group">
<span className="travaux-kicker-pill">
<wcs-mat-icon icon="add_circle"></wcs-mat-icon>
Nouveau Chantier
</span>
<div className="d-flex align-items-center gap-2 mb-1">
<wcs-badge shape="pill" className="wcs-info">
<wcs-mat-icon icon="add_circle" size="s" class="mr-1"></wcs-mat-icon>
Nouveau Chantier
</wcs-badge>
{scopedRegionId && (
<wcs-badge shape="pill" className="wcs-secondary">
Région : {scopedRegionId}
</wcs-badge>
)}
</div>
<h1>Déclarer des travaux sur le réseau</h1>
<p>Planifiez une coupure, des restrictions de circulation et les substitutions associées.</p>
</div>
@@ -184,309 +215,397 @@ export default function CreerTravaux() {
<div className="form-header-actions">
<wcs-button className="wcs-secondary" mode="stroked" onClick={() => navigate('/travaux')}>
Annuler
<span>Annuler</span>
</wcs-button>
<wcs-button
className="wcs-primary"
<wcs-button
className="wcs-primary"
onClick={handleCreate}
disabled={saving || !canCreateTravaux}
loading={saving}
>
{saving ? (
<>
<wcs-spinner mode="border" style={{ width: '16px', height: '16px', marginRight: '6px' }}></wcs-spinner>
<span>Enregistrement...</span>
</>
) : (
<>
<wcs-mat-icon icon="save" style={{ marginRight: '6px' }}></wcs-mat-icon>
<span>Créer le chantier</span>
</>
)}
<wcs-mat-icon icon="save" size="s" class="mr-1"></wcs-mat-icon>
<span>Créer le chantier</span>
</wcs-button>
</div>
</header>
{/* Navigation par Onglets WCS */}
<div className="mb-4">
<wcs-tabs ref={tabsRef} selected-key={activeTabKey}>
<wcs-tab header="1. Général & Ligne" item-key="general"></wcs-tab>
<wcs-tab header="2. Calendrier & Période" item-key="calendrier"></wcs-tab>
<wcs-tab header={`3. Substitutions (${horairesSelectionnes.length})`} item-key="substitutions"></wcs-tab>
</wcs-tabs>
</div>
<div className="travaux-form-grid">
{/* Section 1 : Informations générales */}
<div className="travaux-glass-card">
<div className="section-title-wrap">
<div className="section-icon-box">
<wcs-mat-icon icon="info"></wcs-mat-icon>
</div>
<div>
<h2 className="travaux-section-title">Informations Générales</h2>
<p className="travaux-section-subtitle">Définissez l'intitulé, la ligne concernée et les consignes d'exploitation.</p>
</div>
</div>
<div className="premium-input-group">
<label className="premium-label">Titre des travaux *</label>
<input
type="text"
className="premium-input"
placeholder="Ex: Rénovation caténaire et voies Paris vers Lyon"
value={titre}
onChange={(e) => setTitre(e.target.value)}
/>
</div>
<div className="premium-input-group">
<label className="premium-label">Ligne ferroviaire associée</label>
<select
className="premium-input"
value={selectedLigneId}
onChange={(e) => setSelectedLigneId(e.target.value)}
disabled={loadingLignes}
>
<option value="">Sélectionner une ligne (optionnel)</option>
{lignes.map(l => (
<option key={l.id} value={l.id}>{l.nom || l.numero || `Ligne ${l.id}`}</option>
))}
</select>
</div>
<div className="premium-input-group">
<label className="premium-label">Description détaillée & Informations voyageurs</label>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Détaillez les impacts prévus, motifs techniques, gares concernées..."
/>
</div>
</div>
{/* Section 2 : Planification & Calendrier */}
<div className="travaux-glass-card">
<div className="section-title-wrap">
<div className="section-icon-box">
<wcs-mat-icon icon="calendar_month"></wcs-mat-icon>
</div>
<div>
<h2 className="travaux-section-title">Planification & Horaires</h2>
<p className="travaux-section-subtitle">Période de validité du chantier, jours impactés et créneaux journaliers.</p>
</div>
</div>
<div className="form-columns-2">
<div className="premium-input-group">
<label className="premium-label">Date de début *</label>
<input type="date" className="premium-input" value={dateDebut} onChange={(e) => setDateDebut(e.target.value)} />
</div>
<div className="premium-input-group">
<label className="premium-label">Date de fin *</label>
<input type="date" className="premium-input" value={dateFin} onChange={(e) => setDateFin(e.target.value)} />
</div>
</div>
<div className="form-columns-2">
<div className="premium-input-group">
<label className="premium-label">Heure de début journalière *</label>
<input type="time" className="premium-input" value={heureDebut} onChange={(e) => setHeureDebut(e.target.value)} />
</div>
<div className="premium-input-group">
<label className="premium-label">Heure de fin journalière *</label>
<input type="time" className="premium-input" value={heureFin} onChange={(e) => setHeureFin(e.target.value)} />
</div>
</div>
<div className="premium-input-group" style={{ marginTop: '12px' }}>
<div className="days-header-row">
<label className="premium-label">Jours impactés dans la semaine *</label>
<div className="days-shortcuts">
<button type="button" className="btn-shortcut" onClick={selectAllDays}>Tous les jours</button>
<button type="button" className="btn-shortcut" onClick={selectWeekdays}>Semaine</button>
<button type="button" className="btn-shortcut" onClick={selectWeekend}>Week-end</button>
</div>
</div>
<div className="premium-checkbox-group">
{JOURS_SEMAINE.map((jour) => (
<label key={jour.value} className={`premium-checkbox-label ${joursTravaux.includes(jour.value) ? 'selected' : ''}`}>
<input type="checkbox" checked={joursTravaux.includes(jour.value)} onChange={() => toggleJour(jour.value)} />
<span>{jour.label}</span>
</label>
))}
</div>
</div>
</div>
{/* Section 3 : Substitutions */}
<div className="travaux-glass-card">
<div className="section-title-wrap">
<div className="section-icon-box">
<wcs-mat-icon icon="swap_horiz"></wcs-mat-icon>
</div>
<div>
<h2 className="travaux-section-title">Plan de Transport de Substitution</h2>
<p className="travaux-section-subtitle">Associez des cars ou trains spéciaux pour remplacer les circulations supprimées.</p>
</div>
</div>
<label className={`premium-toggle-switch ${substitutionsActive ? 'active' : ''}`}>
<input
type="checkbox"
checked={substitutionsActive}
onChange={(e) => setSubstitutionsActive(e.target.checked)}
/>
<span className="switch-slider"></span>
<span className="switch-text">
<strong>Activer le plan de substitution pour ce chantier</strong>
<small>Permet de relier des horaires de cars ou trains de substitution actifs</small>
</span>
</label>
{substitutionsActive && (
<div className="substitutions-module-body">
<div className="substitutions-search-bar">
<div className="search-input-wrap">
<wcs-mat-icon icon="search" className="search-icon"></wcs-mat-icon>
<input
type="text"
className="premium-input"
placeholder="Rechercher par numéro de train, gare de départ ou terminus..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearchHoraires(); }}
/>
{/* Onglet 1 : Informations Générales */}
{(activeTabKey === 'general' || activeTabKey === 'all') && (
<wcs-card className="wcs-card-form mb-4">
<wcs-card-body>
<div className="section-title-wrap mb-3">
<div className="section-icon-box">
<wcs-mat-icon icon="info" size="m"></wcs-mat-icon>
</div>
<wcs-button
className="wcs-primary"
onClick={handleSearchHoraires}
disabled={loadingHoraires}
<div>
<h2 className="travaux-section-title">Informations Générales</h2>
<p className="travaux-section-subtitle">Définissez l'intitulé, la ligne concernée et les consignes d'exploitation.</p>
</div>
</div>
<wcs-form-field class="mb-3">
<label htmlFor="titre-travaux">Titre des travaux *</label>
<input
id="titre-travaux"
type="text"
className="form-control"
placeholder="Ex: Rénovation caténaire et voies Paris vers Lyon"
value={titre}
onChange={(e) => setTitre(e.target.value)}
/>
</wcs-form-field>
<wcs-form-field class="mb-3">
<label htmlFor="ligne-select">Ligne ferroviaire associée</label>
<select
id="ligne-select"
className="form-control"
value={selectedLigneId}
onChange={(e) => setSelectedLigneId(e.target.value)}
disabled={loadingLignes}
>
<wcs-mat-icon icon="search" style={{ marginRight: '6px' }}></wcs-mat-icon>
<span>Rechercher</span>
</wcs-button>
<option value="">Sélectionner une ligne (optionnel)</option>
{lignes.map(l => (
<option key={l.id} value={l.id}>{l.nom || l.numero || `Ligne ${l.id}`}</option>
))}
</select>
</wcs-form-field>
<div className="mb-3">
<label className="form-label font-weight-bold d-block mb-2">Description détaillée & Informations voyageurs</label>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Détaillez les impacts prévus, motifs techniques, gares concernées..."
/>
</div>
</wcs-card-body>
</wcs-card>
)}
{/* Onglet 2 : Planification & Calendrier */}
{(activeTabKey === 'calendrier' || activeTabKey === 'all') && (
<wcs-card className="wcs-card-form mb-4">
<wcs-card-body>
<div className="section-title-wrap mb-3">
<div className="section-icon-box">
<wcs-mat-icon icon="calendar_month" size="m"></wcs-mat-icon>
</div>
<div>
<h2 className="travaux-section-title">Planification & Horaires</h2>
<p className="travaux-section-subtitle">Période de validité du chantier, jours impactés et créneaux journaliers.</p>
</div>
</div>
{/* Horaires sélectionnés */}
{horairesSelectionnes.length > 0 && (
<div className="selected-subs-container">
<div className="selected-subs-header">
<h4>
<wcs-mat-icon icon="check_circle" style={{ color: '#0088ce', marginRight: '6px' }}></wcs-mat-icon>
{horairesSelectionnes.length} circulation{horairesSelectionnes.length > 1 ? 's' : ''} de substitution sélectionnée{horairesSelectionnes.length > 1 ? 's' : ''}
</h4>
</div>
<div className="selected-subs-list">
{horairesSelectionnes.map(h => (
<div key={h.id} className="selected-sub-card">
<div className="sub-main-col">
<div className="sub-num-badge">
<wcs-mat-icon icon="directions_bus"></wcs-mat-icon>
<strong>{h.numero_train || 'Train'}</strong>
{h.type_train && <wcs-badge className="wcs-info" shape="round">{h.type_train}</wcs-badge>}
</div>
<div className="sub-route">
<span>{h.gare_depart_nom} ({formatHeure(h.heure_depart)})</span>
<wcs-mat-icon icon="arrow_forward" className="arrow-icon"></wcs-mat-icon>
<span>{h.gare_arrivee_nom} ({formatHeure(h.heure_arrivee)})</span>
</div>
</div>
<button
type="button"
className="btn-remove-sub"
onClick={() => toggleHoraireSelection(h)}
title="Retirer cette substitution"
>
<wcs-mat-icon icon="close"></wcs-mat-icon>
</button>
</div>
))}
<div className="form-columns-2 mb-3">
<wcs-form-field>
<label htmlFor="date-debut">Date de début *</label>
<input
id="date-debut"
type="date"
className="form-control"
value={dateDebut}
onChange={(e) => setDateDebut(e.target.value)}
/>
</wcs-form-field>
<wcs-form-field>
<label htmlFor="date-fin">Date de fin *</label>
<input
id="date-fin"
type="date"
className="form-control"
value={dateFin}
onChange={(e) => setDateFin(e.target.value)}
/>
</wcs-form-field>
</div>
<div className="form-columns-2 mb-3">
<wcs-form-field>
<label htmlFor="heure-debut">Heure de début journalière *</label>
<input
id="heure-debut"
type="time"
className="form-control"
value={heureDebut}
onChange={(e) => setHeureDebut(e.target.value)}
/>
</wcs-form-field>
<wcs-form-field>
<label htmlFor="heure-fin">Heure de fin journalière *</label>
<input
id="heure-fin"
type="time"
className="form-control"
value={heureFin}
onChange={(e) => setHeureFin(e.target.value)}
/>
</wcs-form-field>
</div>
<div className="mt-4">
<div className="days-header-row d-flex justify-content-between align-items-center mb-2">
<label className="font-weight-bold mb-0">Jours impactés dans la semaine *</label>
<div className="d-flex gap-2">
<wcs-button mode="stroked" size="s" onClick={selectAllDays}>Tous les jours</wcs-button>
<wcs-button mode="stroked" size="s" onClick={selectWeekdays}>Semaine</wcs-button>
<wcs-button mode="stroked" size="s" onClick={selectWeekend}>Week-end</wcs-button>
</div>
</div>
)}
{/* Résultats de recherche */}
{showResults && (
<div className="search-results-section">
<div className="search-results-header">
<h4>Résultats disponibles ({horairesDisponibles.length})</h4>
<span className="results-hint">Cliquez sur une circulation pour l'ajouter ou la retirer</span>
<div className="d-flex flex-wrap gap-2 mt-2">
{JOURS_SEMAINE.map((jour) => {
const isSelected = joursTravaux.includes(jour.value);
return (
<button
key={jour.value}
type="button"
className={`btn-day-pill ${isSelected ? 'is-selected' : ''}`}
onClick={() => toggleJour(jour.value)}
>
<wcs-mat-icon icon={isSelected ? 'check_circle' : 'circle_outline'} size="s" class="mr-1"></wcs-mat-icon>
<span>{jour.label}</span>
</button>
);
})}
</div>
</div>
</wcs-card-body>
</wcs-card>
)}
{/* Onglet 3 : Plan de Substitution */}
{(activeTabKey === 'substitutions' || activeTabKey === 'all') && (
<wcs-card className="wcs-card-form mb-4">
<wcs-card-body>
<div className="section-title-wrap mb-3">
<div className="section-icon-box">
<wcs-mat-icon icon="swap_horiz" size="m"></wcs-mat-icon>
</div>
<div>
<h2 className="travaux-section-title">Plan de Transport de Substitution</h2>
<p className="travaux-section-subtitle">Associez des cars ou trains spéciaux pour remplacer les circulations supprimées.</p>
</div>
</div>
{/* Interrupteur WCS Switch pour substitutions */}
<div className="d-flex align-items-center justify-content-between p-3 mb-4 rounded border bg-light">
<div>
<strong className="d-block text-dark">Activer le plan de substitution pour ce chantier</strong>
<small className="text-muted">Permet de relier des horaires de cars ou trains de substitution actifs</small>
</div>
<label className="switch-container">
<input
type="checkbox"
checked={substitutionsActive}
onChange={(e) => setSubstitutionsActive(e.target.checked)}
/>
<span className="switch-slider"></span>
</label>
</div>
{substitutionsActive && (
<div className="substitutions-module-body">
<div className="substitutions-search-bar d-flex gap-2 mb-3">
<div className="flex-grow-1 position-relative">
<input
type="text"
className="form-control"
placeholder="Rechercher par numéro de train, gare de départ ou terminus..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearchHoraires(); }}
/>
</div>
<wcs-button
className="wcs-primary"
onClick={handleSearchHoraires}
disabled={loadingHoraires}
loading={loadingHoraires}
>
<wcs-mat-icon icon="search" size="s" class="mr-1"></wcs-mat-icon>
<span>Rechercher</span>
</wcs-button>
</div>
{loadingHoraires ? (
<div className="search-loading-box">
<wcs-spinner mode="border"></wcs-spinner>
<p>Recherche des circulations en cours...</p>
</div>
) : horairesDisponibles.length === 0 ? (
<div className="search-empty-box">
<p>Aucun horaire de substitution trouvé pour « {searchQuery} ».</p>
</div>
) : (
<div className="subs-results-list">
{horairesDisponibles.map(h => {
const isSelected = horairesSelectionnes.some(hs => hs.id === h.id);
return (
<div
key={h.id}
className={`sub-result-card ${isSelected ? 'is-selected' : ''}`}
onClick={() => toggleHoraireSelection(h)}
>
<div className="result-info">
<div className="result-badge-row">
<strong>{h.numero_train}</strong>
{h.type_train && <wcs-badge className="wcs-info" shape="round">{h.type_train}</wcs-badge>}
{h.ligne_nom && <wcs-badge className="wcs-secondary" shape="round">{h.ligne_nom}</wcs-badge>}
{/* Horaires sélectionnés */}
{horairesSelectionnes.length > 0 && (
<div className="selected-subs-container mb-4">
<div className="selected-subs-header mb-2 d-flex align-items-center justify-content-between">
<h4 className="m-0 text-primary">
<wcs-mat-icon icon="check_circle" size="s" class="text-success mr-1"></wcs-mat-icon>
{horairesSelectionnes.length} circulation{horairesSelectionnes.length > 1 ? 's' : ''} de substitution sélectionnée{horairesSelectionnes.length > 1 ? 's' : ''}
</h4>
</div>
<div className="selected-subs-list d-flex flex-column gap-2">
{horairesSelectionnes.map(h => (
<div key={h.id} className="selected-sub-card d-flex align-items-center justify-content-between p-2 rounded border bg-white">
<div className="d-flex align-items-center gap-3">
<div className="d-flex align-items-center gap-2">
<wcs-mat-icon icon="directions_bus" size="s"></wcs-mat-icon>
<strong>{h.numero_train || 'Train'}</strong>
{h.type_train && <wcs-badge className="wcs-info" shape="pill">{h.type_train}</wcs-badge>}
</div>
<div className="result-route-row">
<div className="sub-route d-flex align-items-center gap-1 text-muted">
<span>{h.gare_depart_nom} ({formatHeure(h.heure_depart)})</span>
<wcs-mat-icon icon="arrow_forward" className="arrow-icon"></wcs-mat-icon>
<wcs-mat-icon icon="arrow_forward" size="s"></wcs-mat-icon>
<span>{h.gare_arrivee_nom} ({formatHeure(h.heure_arrivee)})</span>
</div>
</div>
<div className="result-checkbox">
<wcs-mat-icon icon={isSelected ? "check_box" : "check_box_outline_blank"} className={isSelected ? "icon-checked" : ""}></wcs-mat-icon>
</div>
<wcs-button
mode="clear"
className="wcs-danger"
size="s"
onClick={() => toggleHoraireSelection(h)}
title="Retirer cette substitution"
>
<wcs-mat-icon icon="close" size="s"></wcs-mat-icon>
</wcs-button>
</div>
);
})}
))}
</div>
</div>
)}
{/* Résultats de recherche */}
{showResults && (
<div className="search-results-section">
<div className="search-results-header mb-2 d-flex align-items-center justify-content-between">
<h4 className="m-0">Résultats disponibles ({horairesDisponibles.length})</h4>
<span className="text-muted small">Cliquez sur une circulation pour l'ajouter ou la retirer</span>
</div>
{loadingHoraires ? (
<div className="text-center p-4">
<wcs-spinner mode="border"></wcs-spinner>
<p className="mt-2 text-muted">Recherche des circulations en cours...</p>
</div>
) : horairesDisponibles.length === 0 ? (
<div className="text-center p-4 bg-light rounded text-muted">
<p className="m-0">Aucun horaire de substitution trouvé pour « {searchQuery} ».</p>
</div>
) : (
<div className="subs-results-list d-flex flex-column gap-2">
{horairesDisponibles.map(h => {
const isSelected = horairesSelectionnes.some(hs => hs.id === h.id);
return (
<div
key={h.id}
className={`sub-result-card d-flex align-items-center justify-content-between p-2 rounded border cursor-pointer ${isSelected ? 'border-primary bg-primary-soft' : 'bg-white'}`}
onClick={() => toggleHoraireSelection(h)}
style={{ cursor: 'pointer' }}
>
<div className="result-info">
<div className="d-flex align-items-center gap-2 mb-1">
<strong>{h.numero_train}</strong>
{h.type_train && <wcs-badge className="wcs-info" shape="pill">{h.type_train}</wcs-badge>}
{h.ligne_nom && <wcs-badge className="wcs-secondary" shape="pill">{h.ligne_nom}</wcs-badge>}
</div>
<div className="d-flex align-items-center gap-1 text-muted small">
<span>{h.gare_depart_nom} ({formatHeure(h.heure_depart)})</span>
<wcs-mat-icon icon="arrow_forward" size="s"></wcs-mat-icon>
<span>{h.gare_arrivee_nom} ({formatHeure(h.heure_arrivee)})</span>
</div>
</div>
<div>
<wcs-badge className={isSelected ? 'wcs-success' : 'wcs-secondary'} shape="pill">
<wcs-mat-icon icon={isSelected ? 'check' : 'add'} size="s"></wcs-mat-icon>
<span>{isSelected ? 'Sélectionné' : 'Ajouter'}</span>
</wcs-badge>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
</wcs-card-body>
</wcs-card>
)}
</div>
{/* Bottom Action Bar */}
<div className="form-sticky-bottom-bar">
<label className={`premium-checkbox-label ${actif ? 'selected' : ''}`}>
<input type="checkbox" checked={actif} onChange={(e) => setActif(e.target.checked)} />
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<wcs-badge className={actif ? "wcs-success" : "wcs-secondary"} shape="round">
<wcs-mat-icon icon={actif ? "visibility" : "visibility_off"} style={{ fontSize: '13px', marginRight: '4px' }}></wcs-mat-icon>
{actif ? 'Chantier Actif et visible' : 'Chantier Inactif / Masqué'}
</wcs-badge>
</span>
</label>
{/* Barre d'action inférieure sticky avec Switch d'activation et WCS buttons */}
<div className="form-sticky-bottom-bar d-flex justify-content-between align-items-center p-3 rounded shadow-sm bg-white border mt-4">
<div className="d-flex align-items-center gap-3">
<label className="switch-container">
<input
type="checkbox"
checked={actif}
onChange={(e) => setActif(e.target.checked)}
/>
<span className="switch-slider"></span>
</label>
<div>
<strong className="d-block">{actif ? 'Chantier Actif et visible' : 'Chantier Inactif / Masqué'}</strong>
<small className="text-muted">{actif ? 'Ce chantier sera diffusé aux usagers et aux agents' : 'Ce chantier restera en brouillon non diffusé'}</small>
</div>
</div>
<div className="bottom-bar-btns">
<div className="d-flex gap-2">
<wcs-button className="wcs-secondary" mode="stroked" onClick={() => navigate('/travaux')}>
Annuler
<span>Annuler</span>
</wcs-button>
<wcs-button
className="wcs-primary"
<wcs-button
className="wcs-primary"
onClick={handleCreate}
disabled={saving || !canCreateTravaux}
loading={saving}
>
{saving ? (
<>
<wcs-spinner mode="border" style={{ width: '16px', height: '16px', marginRight: '6px' }}></wcs-spinner>
<span>Enregistrement...</span>
</>
) : (
<>
<wcs-mat-icon icon="check" style={{ marginRight: '6px' }}></wcs-mat-icon>
<span>Enregistrer les travaux</span>
</>
)}
<wcs-mat-icon icon="check" size="s" class="mr-1"></wcs-mat-icon>
<span>Enregistrer le chantier</span>
</wcs-button>
</div>
</div>
{/* WCS Footer Corporate SNCF */}
<wcs-footer className="sncf-page-footer mt-5">
<div className="d-flex align-items-center gap-3">
<div className="footer-sncf-badge">
<wcs-mat-icon icon="construction" style={{ fontSize: '20px', color: '#ffffff' }}></wcs-mat-icon>
</div>
<div>
<strong style={{ color: '#ffffff', fontSize: '14px', display: 'block' }}>
FERROVIA PANEL · DIRECTION TECHNIQUE & INFRASTRUCTURE SNCF
</strong>
<span style={{ color: 'rgba(255, 255, 255, 0.75)', fontSize: '12px' }}>
Module Officiel de Planification des Travaux & Substitutions Routières
</span>
</div>
</div>
<div slot="end-left">
<span style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.65)' }}>
© {new Date().getFullYear()} SNCF · Réseau Ferroviaire National
</span>
</div>
<div slot="end-right">
<div className="d-flex align-items-center gap-2">
<wcs-badge className="wcs-success" shape="rounded">
<wcs-mat-icon icon="cloud_done" class="mr-1"></wcs-mat-icon>
Supabase Connecté
</wcs-badge>
<span style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.7)' }}>
v2.6.0-WCS
</span>
</div>
</div>
</wcs-footer>
</div>
</div>
</div>
+403 -283
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import NavPanel from '../../components/nav-panel/NavPanel';
import { getTravauxById, updateTravaux, getHorairesSubstitution } from '../../api/travaux';
import { getAllLignes } from '../../api/lignes';
@@ -23,6 +23,9 @@ export default function ModifierTravaux() {
const path = window.location.pathname;
const id = path.replace('/travaux/modifier/', '').split('/')[0];
const [activeTabKey, setActiveTabKey] = useState('general');
const tabsRef = useRef(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -65,6 +68,21 @@ export default function ModifierTravaux() {
fetchLignes();
}, [scopedRegionId]);
// Synchronisation des wcs-tabs
useEffect(() => {
const el = tabsRef.current;
if (!el) return;
const handleTabChange = (e) => {
if (e.detail?.selectedKey) {
setActiveTabKey(e.detail.selectedKey);
}
};
el.addEventListener('wcsChange', handleTabChange);
return () => {
el.removeEventListener('wcsChange', handleTabChange);
};
}, []);
const loadTravail = useCallback(async () => {
setLoading(true);
try {
@@ -159,7 +177,7 @@ export default function ModifierTravaux() {
const handleUpdate = async () => {
if (!canUpdateTravaux) {
alert("Vous n'avez pas les droits pour modifier des travaux.");
alert("Vous n'avez pas les droits pour modifier ces travaux.");
return;
}
if (!titre.trim() || !dateDebut || !dateFin || joursTravaux.length === 0 || !heureDebut || !heureFin) {
@@ -187,15 +205,16 @@ export default function ModifierTravaux() {
ligne_nom: selectedLigneId ? (lignes.find(l => l.id === selectedLigneId)?.nom || null) : null
};
const subsIds = substitutionsActive ? horairesSelectionnes.map(h => h.id) : [];
const { error } = await updateTravaux(id, payload, subsIds);
const substitutionsIds = substitutionsActive ? horairesSelectionnes.map(h => h.id) : [];
const { error } = await updateTravaux(id, payload, substitutionsIds);
if (error) {
alert('Erreur lors de la mise à jour : ' + (error.message || 'Erreur'));
alert('Erreur lors de la modification des travaux : ' + (error.message || 'Erreur'));
} else {
navigate('/travaux');
}
} catch (err) {
alert("Une erreur inattendue s'est produite lors de la mise à jour.");
alert("Une erreur inattendue s'est produite");
} finally {
setSaving(false);
}
@@ -206,9 +225,9 @@ export default function ModifierTravaux() {
<div className="sncf-groupe travaux-page-root">
<div className="panel-layout">
<div className="panel-nav"><NavPanel /></div>
<div className="panel-content travaux-form-page-content" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
<wcs-spinner mode="border" style={{ width: '48px', height: '48px', color: '#0088ce' }}></wcs-spinner>
<p style={{ marginTop: '16px', color: '#64748b', fontWeight: 600 }}>Chargement des données du chantier...</p>
<div className="panel-content text-center p-5">
<wcs-spinner mode="border"></wcs-spinner>
<p className="mt-3 text-muted">Chargement des données du chantier...</p>
</div>
</div>
</div>
@@ -221,330 +240,431 @@ export default function ModifierTravaux() {
<div className="panel-nav">
<NavPanel />
</div>
<div className="panel-content travaux-form-page-content">
{/* Header Banner */}
{/* Header Banner SNCF avec WCS */}
<header className="travaux-form-header-modern">
<div className="form-header-main">
<button type="button" className="btn-back-link" onClick={() => navigate('/travaux')}>
<wcs-mat-icon icon="arrow_back"></wcs-mat-icon>
<wcs-button
mode="stroked"
size="s"
className="wcs-secondary mb-2"
onClick={() => navigate('/travaux')}
>
<wcs-mat-icon icon="arrow_back" size="s" class="mr-1"></wcs-mat-icon>
<span>Retour aux travaux</span>
</button>
</wcs-button>
<div className="form-title-group">
<span className="travaux-kicker-pill">
<wcs-mat-icon icon="edit"></wcs-mat-icon>
Modification Chantier
</span>
<h1>Modifier les travaux : {titre || 'Chantier'}</h1>
<p>Mettez à jour les caractéristiques, le calendrier et les substitutions associées.</p>
<div className="d-flex align-items-center gap-2 mb-1">
<wcs-badge shape="pill" className="wcs-warning">
<wcs-mat-icon icon="edit" size="s" class="mr-1"></wcs-mat-icon>
Modification Chantier #{id}
</wcs-badge>
{scopedRegionId && (
<wcs-badge shape="pill" className="wcs-secondary">
Région : {scopedRegionId}
</wcs-badge>
)}
</div>
<h1>Modifier le chantier de travaux</h1>
<p>Ajustez les dates, les contraintes de voie et le plan de substitution associé.</p>
</div>
</div>
<div className="form-header-actions">
<wcs-button className="wcs-secondary" mode="stroked" onClick={() => navigate('/travaux')}>
Annuler
<span>Annuler</span>
</wcs-button>
<wcs-button
className="wcs-primary"
<wcs-button
className="wcs-primary"
onClick={handleUpdate}
disabled={saving || !canUpdateTravaux}
loading={saving}
>
{saving ? (
<>
<wcs-spinner mode="border" style={{ width: '16px', height: '16px', marginRight: '6px' }}></wcs-spinner>
<span>Enregistrement...</span>
</>
) : (
<>
<wcs-mat-icon icon="save" style={{ marginRight: '6px' }}></wcs-mat-icon>
<span>Enregistrer les modifications</span>
</>
)}
<wcs-mat-icon icon="save" size="s" class="mr-1"></wcs-mat-icon>
<span>Mettre à jour le chantier</span>
</wcs-button>
</div>
</header>
{/* Navigation par Onglets WCS */}
<div className="mb-4">
<wcs-tabs ref={tabsRef} selected-key={activeTabKey}>
<wcs-tab header="1. Général & Ligne" item-key="general"></wcs-tab>
<wcs-tab header="2. Calendrier & Période" item-key="calendrier"></wcs-tab>
<wcs-tab header={`3. Substitutions (${horairesSelectionnes.length})`} item-key="substitutions"></wcs-tab>
</wcs-tabs>
</div>
<div className="travaux-form-grid">
{/* Section 1 : Informations générales */}
<div className="travaux-glass-card">
<div className="section-title-wrap">
<div className="section-icon-box">
<wcs-mat-icon icon="info"></wcs-mat-icon>
</div>
<div>
<h2 className="travaux-section-title">Informations Générales</h2>
<p className="travaux-section-subtitle">Définissez l'intitulé, la ligne concernée et les consignes d'exploitation.</p>
</div>
</div>
<div className="premium-input-group">
<label className="premium-label">Titre des travaux *</label>
<input
type="text"
className="premium-input"
placeholder="Ex: Rénovation caténaire et voies Paris vers Lyon"
value={titre}
onChange={(e) => setTitre(e.target.value)}
/>
</div>
<div className="premium-input-group">
<label className="premium-label">Ligne ferroviaire associée</label>
<select
className="premium-input"
value={selectedLigneId}
onChange={(e) => setSelectedLigneId(e.target.value)}
disabled={loadingLignes}
>
<option value="">Sélectionner une ligne (optionnel)</option>
{lignes.map(l => (
<option key={l.id} value={l.id}>{l.nom || l.numero || `Ligne ${l.id}`}</option>
))}
</select>
</div>
<div className="premium-input-group">
<label className="premium-label">Description détaillée & Informations voyageurs</label>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Détaillez les impacts prévus, motifs techniques, gares concernées..."
/>
</div>
</div>
{/* Section 2 : Planification & Calendrier */}
<div className="travaux-glass-card">
<div className="section-title-wrap">
<div className="section-icon-box">
<wcs-mat-icon icon="calendar_month"></wcs-mat-icon>
</div>
<div>
<h2 className="travaux-section-title">Planification & Horaires</h2>
<p className="travaux-section-subtitle">Période de validité du chantier, jours impactés et créneaux journaliers.</p>
</div>
</div>
<div className="form-columns-2">
<div className="premium-input-group">
<label className="premium-label">Date de début *</label>
<input type="date" className="premium-input" value={dateDebut} onChange={(e) => setDateDebut(e.target.value)} />
</div>
<div className="premium-input-group">
<label className="premium-label">Date de fin *</label>
<input type="date" className="premium-input" value={dateFin} onChange={(e) => setDateFin(e.target.value)} />
</div>
</div>
<div className="form-columns-2">
<div className="premium-input-group">
<label className="premium-label">Heure de début journalière *</label>
<input type="time" className="premium-input" value={heureDebut} onChange={(e) => setHeureDebut(e.target.value)} />
</div>
<div className="premium-input-group">
<label className="premium-label">Heure de fin journalière *</label>
<input type="time" className="premium-input" value={heureFin} onChange={(e) => setHeureFin(e.target.value)} />
</div>
</div>
<div className="premium-input-group" style={{ marginTop: '12px' }}>
<div className="days-header-row">
<label className="premium-label">Jours impactés dans la semaine *</label>
<div className="days-shortcuts">
<button type="button" className="btn-shortcut" onClick={selectAllDays}>Tous les jours</button>
<button type="button" className="btn-shortcut" onClick={selectWeekdays}>Semaine</button>
<button type="button" className="btn-shortcut" onClick={selectWeekend}>Week-end</button>
</div>
</div>
<div className="premium-checkbox-group">
{JOURS_SEMAINE.map((jour) => (
<label key={jour.value} className={`premium-checkbox-label ${joursTravaux.includes(jour.value) ? 'selected' : ''}`}>
<input type="checkbox" checked={joursTravaux.includes(jour.value)} onChange={() => toggleJour(jour.value)} />
<span>{jour.label}</span>
</label>
))}
</div>
</div>
</div>
{/* Section 3 : Substitutions */}
<div className="travaux-glass-card">
<div className="section-title-wrap">
<div className="section-icon-box">
<wcs-mat-icon icon="swap_horiz"></wcs-mat-icon>
</div>
<div>
<h2 className="travaux-section-title">Plan de Transport de Substitution</h2>
<p className="travaux-section-subtitle">Associez des cars ou trains spéciaux pour remplacer les circulations supprimées.</p>
</div>
</div>
<label className={`premium-toggle-switch ${substitutionsActive ? 'active' : ''}`}>
<input
type="checkbox"
checked={substitutionsActive}
onChange={(e) => setSubstitutionsActive(e.target.checked)}
/>
<span className="switch-slider"></span>
<span className="switch-text">
<strong>Activer le plan de substitution pour ce chantier</strong>
<small>Permet de relier des horaires de cars ou trains de substitution actifs</small>
</span>
</label>
{substitutionsActive && (
<div className="substitutions-module-body">
<div className="substitutions-search-bar">
<div className="search-input-wrap">
<wcs-mat-icon icon="search" className="search-icon"></wcs-mat-icon>
<input
type="text"
className="premium-input"
placeholder="Rechercher par numéro de train, gare de départ ou terminus..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearchHoraires(); }}
/>
{/* Onglet 1 : Informations Générales */}
{(activeTabKey === 'general' || activeTabKey === 'all') && (
<wcs-card className="wcs-card-form mb-4">
<wcs-card-body>
<div className="section-title-wrap mb-3">
<div className="section-icon-box">
<wcs-mat-icon icon="info" size="m"></wcs-mat-icon>
</div>
<wcs-button
className="wcs-primary"
onClick={handleSearchHoraires}
disabled={loadingHoraires}
<div>
<h2 className="travaux-section-title">Informations Générales</h2>
<p className="travaux-section-subtitle">Définissez l'intitulé, la ligne concernée et les consignes d'exploitation.</p>
</div>
</div>
<wcs-form-field class="mb-3">
<label htmlFor="titre-travaux">Titre des travaux *</label>
<input
id="titre-travaux"
type="text"
className="form-control"
placeholder="Ex: Rénovation caténaire et voies Paris vers Lyon"
value={titre}
onChange={(e) => setTitre(e.target.value)}
/>
</wcs-form-field>
<wcs-form-field class="mb-3">
<label htmlFor="ligne-select">Ligne ferroviaire associée</label>
<select
id="ligne-select"
className="form-control"
value={selectedLigneId}
onChange={(e) => setSelectedLigneId(e.target.value)}
disabled={loadingLignes}
>
<wcs-mat-icon icon="search" style={{ marginRight: '6px' }}></wcs-mat-icon>
<span>Rechercher</span>
</wcs-button>
<option value="">Sélectionner une ligne (optionnel)</option>
{lignes.map(l => (
<option key={l.id} value={l.id}>{l.nom || l.numero || `Ligne ${l.id}`}</option>
))}
</select>
</wcs-form-field>
<div className="mb-3">
<label className="form-label font-weight-bold d-block mb-2">Description détaillée & Informations voyageurs</label>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Détaillez les impacts prévus, motifs techniques, gares concernées..."
/>
</div>
</wcs-card-body>
</wcs-card>
)}
{/* Onglet 2 : Planification & Calendrier */}
{(activeTabKey === 'calendrier' || activeTabKey === 'all') && (
<wcs-card className="wcs-card-form mb-4">
<wcs-card-body>
<div className="section-title-wrap mb-3">
<div className="section-icon-box">
<wcs-mat-icon icon="calendar_month" size="m"></wcs-mat-icon>
</div>
<div>
<h2 className="travaux-section-title">Planification & Horaires</h2>
<p className="travaux-section-subtitle">Période de validité du chantier, jours impactés et créneaux journaliers.</p>
</div>
</div>
{/* Horaires sélectionnés */}
{horairesSelectionnes.length > 0 && (
<div className="selected-subs-container">
<div className="selected-subs-header">
<h4>
<wcs-mat-icon icon="check_circle" style={{ color: '#0088ce', marginRight: '6px' }}></wcs-mat-icon>
{horairesSelectionnes.length} circulation{horairesSelectionnes.length > 1 ? 's' : ''} de substitution sélectionnée{horairesSelectionnes.length > 1 ? 's' : ''}
</h4>
</div>
<div className="selected-subs-list">
{horairesSelectionnes.map(h => (
<div key={h.id} className="selected-sub-card">
<div className="sub-main-col">
<div className="sub-num-badge">
<wcs-mat-icon icon="directions_bus"></wcs-mat-icon>
<strong>{h.numero_train || 'Train'}</strong>
{h.type_train && <wcs-badge className="wcs-info" shape="round">{h.type_train}</wcs-badge>}
</div>
<div className="sub-route">
<span>{h.gare_depart_nom} ({formatHeure(h.heure_depart)})</span>
<wcs-mat-icon icon="arrow_forward" className="arrow-icon"></wcs-mat-icon>
<span>{h.gare_arrivee_nom} ({formatHeure(h.heure_arrivee)})</span>
</div>
</div>
<button
type="button"
className="btn-remove-sub"
onClick={() => toggleHoraireSelection(h)}
title="Retirer cette substitution"
>
<wcs-mat-icon icon="close"></wcs-mat-icon>
</button>
</div>
))}
<div className="form-columns-2 mb-3">
<wcs-form-field>
<label htmlFor="date-debut">Date de début *</label>
<input
id="date-debut"
type="date"
className="form-control"
value={dateDebut}
onChange={(e) => setDateDebut(e.target.value)}
/>
</wcs-form-field>
<wcs-form-field>
<label htmlFor="date-fin">Date de fin *</label>
<input
id="date-fin"
type="date"
className="form-control"
value={dateFin}
onChange={(e) => setDateFin(e.target.value)}
/>
</wcs-form-field>
</div>
<div className="form-columns-2 mb-3">
<wcs-form-field>
<label htmlFor="heure-debut">Heure de début journalière *</label>
<input
id="heure-debut"
type="time"
className="form-control"
value={heureDebut}
onChange={(e) => setHeureDebut(e.target.value)}
/>
</wcs-form-field>
<wcs-form-field>
<label htmlFor="heure-fin">Heure de fin journalière *</label>
<input
id="heure-fin"
type="time"
className="form-control"
value={heureFin}
onChange={(e) => setHeureFin(e.target.value)}
/>
</wcs-form-field>
</div>
<div className="mt-4">
<div className="days-header-row d-flex justify-content-between align-items-center mb-2">
<label className="font-weight-bold mb-0">Jours impactés dans la semaine *</label>
<div className="d-flex gap-2">
<wcs-button mode="stroked" size="s" onClick={selectAllDays}>Tous les jours</wcs-button>
<wcs-button mode="stroked" size="s" onClick={selectWeekdays}>Semaine</wcs-button>
<wcs-button mode="stroked" size="s" onClick={selectWeekend}>Week-end</wcs-button>
</div>
</div>
)}
{/* Résultats de recherche */}
{showResults && (
<div className="search-results-section">
<div className="search-results-header">
<h4>Résultats disponibles ({horairesDisponibles.length})</h4>
<span className="results-hint">Cliquez sur une circulation pour l'ajouter ou la retirer</span>
<div className="d-flex flex-wrap gap-2 mt-2">
{JOURS_SEMAINE.map((jour) => {
const isSelected = (joursTravaux || []).includes(jour.value);
return (
<button
key={jour.value}
type="button"
className={`btn-day-pill ${isSelected ? 'is-selected' : ''}`}
onClick={() => toggleJour(jour.value)}
>
<wcs-mat-icon icon={isSelected ? 'check_circle' : 'circle_outline'} size="s" class="mr-1"></wcs-mat-icon>
<span>{jour.label}</span>
</button>
);
})}
</div>
</div>
</wcs-card-body>
</wcs-card>
)}
{/* Onglet 3 : Plan de Substitution */}
{(activeTabKey === 'substitutions' || activeTabKey === 'all') && (
<wcs-card className="wcs-card-form mb-4">
<wcs-card-body>
<div className="section-title-wrap mb-3">
<div className="section-icon-box">
<wcs-mat-icon icon="swap_horiz" size="m"></wcs-mat-icon>
</div>
<div>
<h2 className="travaux-section-title">Plan de Transport de Substitution</h2>
<p className="travaux-section-subtitle">Associez des cars ou trains spéciaux pour remplacer les circulations supprimées.</p>
</div>
</div>
{/* Interrupteur WCS Switch pour substitutions */}
<div className="d-flex align-items-center justify-content-between p-3 mb-4 rounded border bg-light">
<div>
<strong className="d-block text-dark">Activer le plan de substitution pour ce chantier</strong>
<small className="text-muted">Permet de relier des horaires de cars ou trains de substitution actifs</small>
</div>
<label className="switch-container">
<input
type="checkbox"
checked={substitutionsActive}
onChange={(e) => setSubstitutionsActive(e.target.checked)}
/>
<span className="switch-slider"></span>
</label>
</div>
{substitutionsActive && (
<div className="substitutions-module-body">
<div className="substitutions-search-bar d-flex gap-2 mb-3">
<div className="flex-grow-1 position-relative">
<input
type="text"
className="form-control"
placeholder="Rechercher par numéro de train, gare de départ ou terminus..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearchHoraires(); }}
/>
</div>
<wcs-button
className="wcs-primary"
onClick={handleSearchHoraires}
disabled={loadingHoraires}
loading={loadingHoraires}
>
<wcs-mat-icon icon="search" size="s" class="mr-1"></wcs-mat-icon>
<span>Rechercher</span>
</wcs-button>
</div>
{loadingHoraires ? (
<div className="search-loading-box">
<wcs-spinner mode="border"></wcs-spinner>
<p>Recherche des circulations en cours...</p>
</div>
) : horairesDisponibles.length === 0 ? (
<div className="search-empty-box">
<p>Aucun horaire de substitution trouvé pour « {searchQuery} ».</p>
</div>
) : (
<div className="subs-results-list">
{horairesDisponibles.map(h => {
const isSelected = horairesSelectionnes.some(hs => hs.id === h.id);
return (
<div
key={h.id}
className={`sub-result-card ${isSelected ? 'is-selected' : ''}`}
onClick={() => toggleHoraireSelection(h)}
>
<div className="result-info">
<div className="result-badge-row">
<strong>{h.numero_train}</strong>
{h.type_train && <wcs-badge className="wcs-info" shape="round">{h.type_train}</wcs-badge>}
{h.ligne_nom && <wcs-badge className="wcs-secondary" shape="round">{h.ligne_nom}</wcs-badge>}
{/* Horaires sélectionnés */}
{horairesSelectionnes.length > 0 && (
<div className="selected-subs-container mb-4">
<div className="selected-subs-header mb-2 d-flex align-items-center justify-content-between">
<h4 className="m-0 text-primary">
<wcs-mat-icon icon="check_circle" size="s" class="text-success mr-1"></wcs-mat-icon>
{horairesSelectionnes.length} circulation{horairesSelectionnes.length > 1 ? 's' : ''} de substitution sélectionnée{horairesSelectionnes.length > 1 ? 's' : ''}
</h4>
</div>
<div className="selected-subs-list d-flex flex-column gap-2">
{horairesSelectionnes.map(h => (
<div key={h.id} className="selected-sub-card d-flex align-items-center justify-content-between p-2 rounded border bg-white">
<div className="d-flex align-items-center gap-3">
<div className="d-flex align-items-center gap-2">
<wcs-mat-icon icon="directions_bus" size="s"></wcs-mat-icon>
<strong>{h.numero_train || 'Train'}</strong>
{h.type_train && <wcs-badge className="wcs-info" shape="pill">{h.type_train}</wcs-badge>}
</div>
<div className="result-route-row">
<div className="sub-route d-flex align-items-center gap-1 text-muted">
<span>{h.gare_depart_nom} ({formatHeure(h.heure_depart)})</span>
<wcs-mat-icon icon="arrow_forward" className="arrow-icon"></wcs-mat-icon>
<wcs-mat-icon icon="arrow_forward" size="s"></wcs-mat-icon>
<span>{h.gare_arrivee_nom} ({formatHeure(h.heure_arrivee)})</span>
</div>
</div>
<div className="result-checkbox">
<wcs-mat-icon icon={isSelected ? "check_box" : "check_box_outline_blank"} className={isSelected ? "icon-checked" : ""}></wcs-mat-icon>
</div>
<wcs-button
mode="clear"
className="wcs-danger"
size="s"
onClick={() => toggleHoraireSelection(h)}
title="Retirer cette substitution"
>
<wcs-mat-icon icon="close" size="s"></wcs-mat-icon>
</wcs-button>
</div>
);
})}
))}
</div>
</div>
)}
{/* Résultats de recherche */}
{showResults && (
<div className="search-results-section">
<div className="search-results-header mb-2 d-flex align-items-center justify-content-between">
<h4 className="m-0">Résultats disponibles ({horairesDisponibles.length})</h4>
<span className="text-muted small">Cliquez sur une circulation pour l'ajouter ou la retirer</span>
</div>
{loadingHoraires ? (
<div className="text-center p-4">
<wcs-spinner mode="border"></wcs-spinner>
<p className="mt-2 text-muted">Recherche des circulations en cours...</p>
</div>
) : horairesDisponibles.length === 0 ? (
<div className="text-center p-4 bg-light rounded text-muted">
<p className="m-0">Aucun horaire de substitution trouvé pour « {searchQuery} ».</p>
</div>
) : (
<div className="subs-results-list d-flex flex-column gap-2">
{horairesDisponibles.map(h => {
const isSelected = horairesSelectionnes.some(hs => hs.id === h.id);
return (
<div
key={h.id}
className={`sub-result-card d-flex align-items-center justify-content-between p-2 rounded border cursor-pointer ${isSelected ? 'border-primary bg-primary-soft' : 'bg-white'}`}
onClick={() => toggleHoraireSelection(h)}
style={{ cursor: 'pointer' }}
>
<div className="result-info">
<div className="d-flex align-items-center gap-2 mb-1">
<strong>{h.numero_train}</strong>
{h.type_train && <wcs-badge className="wcs-info" shape="pill">{h.type_train}</wcs-badge>}
{h.ligne_nom && <wcs-badge className="wcs-secondary" shape="pill">{h.ligne_nom}</wcs-badge>}
</div>
<div className="d-flex align-items-center gap-1 text-muted small">
<span>{h.gare_depart_nom} ({formatHeure(h.heure_depart)})</span>
<wcs-mat-icon icon="arrow_forward" size="s"></wcs-mat-icon>
<span>{h.gare_arrivee_nom} ({formatHeure(h.heure_arrivee)})</span>
</div>
</div>
<div>
<wcs-badge className={isSelected ? 'wcs-success' : 'wcs-secondary'} shape="pill">
<wcs-mat-icon icon={isSelected ? 'check' : 'add'} size="s"></wcs-mat-icon>
<span>{isSelected ? 'Sélectionné' : 'Ajouter'}</span>
</wcs-badge>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
</wcs-card-body>
</wcs-card>
)}
</div>
{/* Bottom Action Bar */}
<div className="form-sticky-bottom-bar">
<label className={`premium-checkbox-label ${actif ? 'selected' : ''}`}>
<input type="checkbox" checked={actif} onChange={(e) => setActif(e.target.checked)} />
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<wcs-badge className={actif ? "wcs-success" : "wcs-secondary"} shape="round">
<wcs-mat-icon icon={actif ? "visibility" : "visibility_off"} style={{ fontSize: '13px', marginRight: '4px' }}></wcs-mat-icon>
{actif ? 'Chantier Actif et visible' : 'Chantier Inactif / Masqué'}
</wcs-badge>
</span>
</label>
{/* Barre d'action inférieure sticky avec Switch d'activation et WCS buttons */}
<div className="form-sticky-bottom-bar d-flex justify-content-between align-items-center p-3 rounded shadow-sm bg-white border mt-4">
<div className="d-flex align-items-center gap-3">
<label className="switch-container">
<input
type="checkbox"
checked={actif}
onChange={(e) => setActif(e.target.checked)}
/>
<span className="switch-slider"></span>
</label>
<div>
<strong className="d-block">{actif ? 'Chantier Actif et visible' : 'Chantier Inactif / Masqué'}</strong>
<small className="text-muted">{actif ? 'Ce chantier sera diffusé aux usagers et aux agents' : 'Ce chantier restera en brouillon non diffusé'}</small>
</div>
</div>
<div className="bottom-bar-btns">
<div className="d-flex gap-2">
<wcs-button className="wcs-secondary" mode="stroked" onClick={() => navigate('/travaux')}>
Annuler
<span>Annuler</span>
</wcs-button>
<wcs-button
className="wcs-primary"
<wcs-button
className="wcs-primary"
onClick={handleUpdate}
disabled={saving || !canUpdateTravaux}
loading={saving}
>
{saving ? (
<>
<wcs-spinner mode="border" style={{ width: '16px', height: '16px', marginRight: '6px' }}></wcs-spinner>
<span>Enregistrement...</span>
</>
) : (
<>
<wcs-mat-icon icon="check" style={{ marginRight: '6px' }}></wcs-mat-icon>
<span>Enregistrer les modifications</span>
</>
)}
<wcs-mat-icon icon="check" size="s" class="mr-1"></wcs-mat-icon>
<span>Mettre à jour le chantier</span>
</wcs-button>
</div>
</div>
{/* WCS Footer Corporate SNCF */}
<wcs-footer className="sncf-page-footer mt-5">
<div className="d-flex align-items-center gap-3">
<div className="footer-sncf-badge">
<wcs-mat-icon icon="construction" style={{ fontSize: '20px', color: '#ffffff' }}></wcs-mat-icon>
</div>
<div>
<strong style={{ color: '#ffffff', fontSize: '14px', display: 'block' }}>
FERROVIA PANEL · DIRECTION TECHNIQUE & INFRASTRUCTURE SNCF
</strong>
<span style={{ color: 'rgba(255, 255, 255, 0.75)', fontSize: '12px' }}>
Module Officiel de Planification des Travaux & Substitutions Routières
</span>
</div>
</div>
<div slot="end-left">
<span style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.65)' }}>
© {new Date().getFullYear()} SNCF · Réseau Ferroviaire National
</span>
</div>
<div slot="end-right">
<div className="d-flex align-items-center gap-2">
<wcs-badge className="wcs-success" shape="rounded">
<wcs-mat-icon icon="cloud_done" class="mr-1"></wcs-mat-icon>
Supabase Connecté
</wcs-badge>
<span style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.7)' }}>
v2.6.0-WCS
</span>
</div>
</div>
</wcs-footer>
</div>
</div>
</div>
+51 -3
View File
@@ -1,7 +1,16 @@
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || "https://supabase-ferrovia.mrpatator.fr";
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || "sb_publishable_6dB95QNDKiHcFD81TJETqq_3Mu5rWWU";
const SUPABASE_URL =
process.env.REACT_APP_SUPABASE_URL ||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
"https://supabase-ferrovia.mrpatator.fr";
const SUPABASE_ANON_KEY =
process.env.REACT_APP_SUPABASE_ANON_KEY ||
process.env.REACT_APP_SUPABASE_PUBLISHABLE_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
"sb_publishable_k8EI4S2YA_6Hl5av8uCBr3_S1Bj5g_F";
const isSupabaseConfigured = !!(SUPABASE_URL && SUPABASE_ANON_KEY);
@@ -11,7 +20,46 @@ if (!isSupabaseConfigured) {
console.warn('REACT_APP_SUPABASE_URL or REACT_APP_SUPABASE_ANON_KEY is not set. Supabase client will be initialized but requests may fail.');
}
const supabase = createClient(SUPABASE_URL || '', SUPABASE_ANON_KEY || '');
/**
* Fetch wrapper personnalisé pour intercepter les erreurs 500 sur le refresh_token.
* Si le serveur GoTrue de Supabase renvoie un statut 500 sur grant_type=refresh_token au lieu de 400 (invalid_grant),
* le client @supabase/supabase-js passe en AuthRetryableFetchError et boucle indéfiniment en spammant le serveur.
* En interceptant la 500 pour renvoyer 400 (invalid_grant), le SDK comprend immédiatement que le token est expiré,
* purge la session locale et stoppe net la boucle de retry.
*/
const customFetch = async (input, init) => {
try {
const response = await fetch(input, init);
const url = typeof input === 'string' ? input : (input?.url || '');
if (url.includes('/auth/v1/token') && url.includes('grant_type=refresh_token') && response.status === 500) {
console.warn('[SupabaseClient] Session expirée ou invalide sur le serveur (500 sur refresh_token). Réinitialisation de la session locale pour stopper la boucle.');
return new Response(
JSON.stringify({
error: 'invalid_grant',
error_description: 'Token de rafraîchissement invalide ou expiré.'
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
return response;
} catch (err) {
throw err;
}
};
const supabase = createClient(SUPABASE_URL || '', SUPABASE_ANON_KEY || '', {
global: {
fetch: customFetch
},
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true
}
});
export { isSupabaseConfigured };
export default supabase;
File diff suppressed because it is too large. Load diff