V5.0.0 //
-> Refonte du système de login -> Récupération du MDP -> Refonte imports d'horaires + Modale de gestion d'horaires
This commit is contained in:
1 parent
d89eaaa17f
commit
36b0c00cbc
23 files changed
+5082
-924
No files matched your search
+50
-14
@@ -257,19 +257,25 @@ CREATE INDEX IF NOT EXISTS "idx_lignes_gares_ligne" ON "public"."lignes_gares" (
|
||||
CREATE INDEX IF NOT EXISTS "idx_lignes_gares_ordre" ON "public"."lignes_gares" ("ligne_id","ordre");
|
||||
|
||||
-- -----------------------------------------------
|
||||
-- 6.11 horaires
|
||||
-- 6.11 horaires (Table unique et consolidée)
|
||||
-- -----------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS "public"."horaires" (
|
||||
"id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
|
||||
"numero_train" varchar(50) NOT NULL,
|
||||
"type_train" varchar(255),
|
||||
"nom_train" varchar(255),
|
||||
"code_mission" varchar(50),
|
||||
"sens" varchar(20) DEFAULT 'aller',
|
||||
"type_train" varchar(100),
|
||||
"type_train_id" bigint REFERENCES "public"."types_train"("id") ON DELETE SET NULL,
|
||||
"transporteur" varchar(100) DEFAULT 'SNCF Voyageurs',
|
||||
"gare_depart_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL,
|
||||
"gare_depart_nom" varchar(255),
|
||||
"gare_depart_nom" varchar(255) NOT NULL,
|
||||
"heure_depart" time,
|
||||
"gare_arrivee_id" uuid REFERENCES "public"."gares"("id") ON DELETE SET NULL,
|
||||
"gare_arrivee_nom" varchar(255),
|
||||
"gare_arrivee_nom" varchar(255) NOT NULL,
|
||||
"heure_arrivee" time,
|
||||
"gares_desservies" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"attributions_quais" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
"circule_lundi" boolean DEFAULT true,
|
||||
"circule_mardi" boolean DEFAULT true,
|
||||
"circule_mercredi" boolean DEFAULT true,
|
||||
@@ -279,27 +285,48 @@ CREATE TABLE IF NOT EXISTS "public"."horaires" (
|
||||
"circule_dimanche" boolean DEFAULT true,
|
||||
"circule_jours_feries" boolean DEFAULT false,
|
||||
"circule_dimanches_feries" boolean DEFAULT false,
|
||||
"jours_personnalises" jsonb DEFAULT '[]'::jsonb,
|
||||
"jours_non_circulation" jsonb DEFAULT '[]'::jsonb,
|
||||
"date_debut" timestamptz,
|
||||
"date_fin" timestamptz,
|
||||
"jours_personnalises" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"jours_non_circulation" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"calendrier_circulation" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"periode_validite" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
"materiel_roulant_id" bigint REFERENCES "public"."materiel-roulant"("id") ON DELETE SET NULL,
|
||||
"composition_train" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"ligne_id" uuid REFERENCES "public"."lignes"("id") ON DELETE SET NULL,
|
||||
"ligne_nom" varchar(255),
|
||||
"region_id" varchar(100),
|
||||
"region" varchar(255),
|
||||
"service_annuel_id" uuid REFERENCES "public"."services_annuels"("id") ON DELETE SET NULL,
|
||||
"service_annuel_nom" varchar(255),
|
||||
"est_substitution" boolean DEFAULT false,
|
||||
"service_type" varchar(50) DEFAULT 'TER',
|
||||
"est_substitution" boolean NOT NULL DEFAULT false,
|
||||
"motif_substitution" varchar(255),
|
||||
"gares_desservies" jsonb DEFAULT '[]'::jsonb,
|
||||
"composition_train" jsonb DEFAULT '[]'::jsonb,
|
||||
"substitution_disponible" boolean DEFAULT false,
|
||||
"actif" boolean DEFAULT true,
|
||||
"region_id" varchar(100),
|
||||
"created_at" timestamptz DEFAULT now(),
|
||||
"updated_at" timestamptz DEFAULT now(),
|
||||
"substitution_disponible" boolean NOT NULL DEFAULT false,
|
||||
"horaire_id" uuid REFERENCES "public"."horaires"("id") ON DELETE SET NULL,
|
||||
"substitutions_associees" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"sillon_id" text,
|
||||
"tranche" varchar(50),
|
||||
"gestionnaire_infrastructure" varchar(100) DEFAULT 'SNCF Réseau',
|
||||
"vitesse_max_kmh" integer,
|
||||
"statut" varchar(50) NOT NULL DEFAULT 'a_l_heure',
|
||||
"retard_minutes" integer NOT NULL DEFAULT 0,
|
||||
"retard_motif" text,
|
||||
"informations_voyageurs" text,
|
||||
"actif" boolean NOT NULL DEFAULT true,
|
||||
"metadata" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
"notes" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz
|
||||
);
|
||||
|
||||
-- Index haute performance B-Tree et GIN
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_numero_train" ON "public"."horaires" ("numero_train");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_gare_depart" ON "public"."horaires" ("gare_depart_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_gare_arrivee" ON "public"."horaires" ("gare_arrivee_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_departs" ON "public"."horaires" ("gare_depart_id", "heure_depart");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_arrivees" ON "public"."horaires" ("gare_arrivee_id", "heure_arrivee");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_actif" ON "public"."horaires" ("actif") WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_type_train" ON "public"."horaires" ("type_train_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_materiel" ON "public"."horaires" ("materiel_roulant_id");
|
||||
@@ -307,6 +334,15 @@ CREATE INDEX IF NOT EXISTS "idx_horaires_ligne" ON "public"."horaires" ("ligne_i
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_service_annuel" ON "public"."horaires" ("service_annuel_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_substitution" ON "public"."horaires" ("est_substitution");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_region_id" ON "public"."horaires" ("region_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_dates" ON "public"."horaires" ("date_debut", "date_fin");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_statut" ON "public"."horaires" ("statut");
|
||||
|
||||
-- Index GIN JSONB pour recherche et filtrage ultra-rapide sans jointure
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_gares_desservies_gin" ON "public"."horaires" USING gin ("gares_desservies" jsonb_path_ops);
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_attributions_quais_gin" ON "public"."horaires" USING gin ("attributions_quais");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_calendrier_gin" ON "public"."horaires" USING gin ("calendrier_circulation" jsonb_path_ops);
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_metadata_gin" ON "public"."horaires" USING gin ("metadata");
|
||||
|
||||
|
||||
-- -----------------------------------------------
|
||||
-- 6.12 horaires_gares_desservies
|
||||
|
||||
+42
-4
@@ -3,8 +3,10 @@ import './App.css';
|
||||
import { applyPolyfills, defineCustomElements } from 'wcs-core/loader';
|
||||
import Login from './pages/login/Login';
|
||||
import ActiverCompte from './pages/activer-compte/ActiverCompte';
|
||||
import ReinitialiserMotDePasse from './pages/reinitialiser-mot-de-passe/ReinitialiserMotDePasse';
|
||||
import PanelRouter from './pages/router/PanelRouter';
|
||||
import AppHeader from './components/header/AppHeader';
|
||||
import { buildUserProfileFromAuth } from './utils/userUtils';
|
||||
|
||||
import supabase from './supabaseClient';
|
||||
|
||||
@@ -32,6 +34,20 @@ function App() {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
|
||||
// Synchronisation de session Supabase Auth au démarrage
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
if (session?.user && !window.location.pathname.startsWith('/reinitialiser-mot-de-passe')) {
|
||||
const stored = window.localStorage.getItem('app_user');
|
||||
if (!stored) {
|
||||
const user = buildUserProfileFromAuth(session.user);
|
||||
if (user && user.actif !== false) {
|
||||
try { window.localStorage.setItem('app_user', JSON.stringify(user)); } catch (_) {}
|
||||
setCurrentUser(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// écouter un événement custom pour synchroniser l'état d'authentification
|
||||
const onAuthChanged = () => {
|
||||
try {
|
||||
@@ -59,9 +75,20 @@ function App() {
|
||||
if (event === 'SIGNED_OUT') {
|
||||
window.localStorage.removeItem('app_user');
|
||||
setCurrentUser(null);
|
||||
if (window.location.pathname !== '/') {
|
||||
if (window.location.pathname !== '/' && !window.location.pathname.startsWith('/reinitialiser-mot-de-passe')) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
|
||||
if (session?.user && !window.location.pathname.startsWith('/reinitialiser-mot-de-passe')) {
|
||||
const stored = window.localStorage.getItem('app_user');
|
||||
if (!stored) {
|
||||
const user = buildUserProfileFromAuth(session.user);
|
||||
if (user && user.actif !== false) {
|
||||
try { window.localStorage.setItem('app_user', JSON.stringify(user)); } catch (_) {}
|
||||
setCurrentUser(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,15 +114,26 @@ function App() {
|
||||
|
||||
// Routing minimal sans react-router.
|
||||
const isActivationPath = pathname.startsWith('/activer-compte');
|
||||
const isPanelPath = pathname.startsWith('/dashboard')
|
||||
const isResetPasswordPath = pathname.startsWith('/reinitialiser-mot-de-passe') || pathname.startsWith('/reset-password');
|
||||
const isPanelPath = !isActivationPath && !isResetPasswordPath && (
|
||||
pathname.startsWith('/dashboard')
|
||||
|| pathname.startsWith('/logout')
|
||||
|| currentUser;
|
||||
|| currentUser
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader isLoggedIn={!!currentUser} userName={currentUser ? (currentUser.name || currentUser.email || '') : ''} />
|
||||
|
||||
{isPanelPath ? <PanelRouter /> : (isActivationPath ? <ActiverCompte /> : <Login onLogin={handleLogin} />)}
|
||||
{isPanelPath ? (
|
||||
<PanelRouter />
|
||||
) : isResetPasswordPath ? (
|
||||
<ReinitialiserMotDePasse />
|
||||
) : isActivationPath ? (
|
||||
<ActiverCompte />
|
||||
) : (
|
||||
<Login onLogin={handleLogin} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+308
-422
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Tests unitaires pour l'API unique consolidée des horaires et substitutions
|
||||
*/
|
||||
|
||||
import {
|
||||
getAllHoraires,
|
||||
getHoraireById,
|
||||
createHoraire,
|
||||
updateHoraire,
|
||||
deleteHoraire,
|
||||
addGareDesservie,
|
||||
updateGareDesservie,
|
||||
deleteGareDesservie,
|
||||
updateQuaisHoraire,
|
||||
getHorairesCountByQuai,
|
||||
reassignGareInHoraires
|
||||
} from './horaires';
|
||||
|
||||
import {
|
||||
getAllSubstitutions,
|
||||
getSubstitutionById,
|
||||
createSubstitution,
|
||||
updateSubstitution,
|
||||
deleteSubstitution
|
||||
} from './horairesSubstitutions';
|
||||
|
||||
// Mock Supabase comme non-configuré par défaut pour tester les fallbacks locaux
|
||||
jest.mock('../supabaseClient', () => ({
|
||||
__esModule: true,
|
||||
default: null
|
||||
}));
|
||||
|
||||
describe('API Horaires (Table unique consolidée)', () => {
|
||||
describe('getAllHoraires (mode hors-ligne)', () => {
|
||||
it('devrait retourner un tableau vide sans erreur si Supabase est indisponible', async () => {
|
||||
const { data, error } = await getAllHoraires();
|
||||
expect(data).toEqual([]);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHoraireById (mode hors-ligne)', () => {
|
||||
it('devrait retourner null avec une erreur explicite', async () => {
|
||||
const { data, error } = await getHoraireById('uuid-123');
|
||||
expect(data).toBeNull();
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHoraire (mode hors-ligne)', () => {
|
||||
it('devrait générer un identifiant local et stocker les dessertes et attributions de quais dans l\'objet unique', async () => {
|
||||
const payload = {
|
||||
numero_train: '854120',
|
||||
gare_depart_nom: 'Gare de Lyon',
|
||||
gare_arrivee_nom: 'Marseille Saint-Charles',
|
||||
heure_depart: '08:00',
|
||||
heure_arrivee: '11:15',
|
||||
type_train: 'TGV'
|
||||
};
|
||||
const dessertes = [
|
||||
{ gare_id: 'g-1', gare_nom: 'Gare de Lyon', ordre: 1, quai: 'A' },
|
||||
{ gare_id: 'g-2', gare_nom: 'Marseille Saint-Charles', ordre: 2, quai: 'C' }
|
||||
];
|
||||
|
||||
const { data, error } = await createHoraire(payload, dessertes);
|
||||
expect(error).toBeNull();
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toMatch(/^local-/);
|
||||
expect(data.numero_train).toBe('854120');
|
||||
expect(data.gares_desservies).toHaveLength(2);
|
||||
expect(data.gares_desservies[0].quai).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateQuaisHoraire (mode hors-ligne)', () => {
|
||||
it('devrait retourner null sans erreur si Supabase est indisponible', async () => {
|
||||
const { data, error } = await updateQuaisHoraire('local-1', { 'depart': 'A', '1': 'B' });
|
||||
expect(error).toBeNull();
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHorairesCountByQuai (mode hors-ligne)', () => {
|
||||
it('devrait retourner un objet data vide sans erreur en fallback', async () => {
|
||||
const { data, error } = await getHorairesCountByQuai('gare-1', 'A');
|
||||
expect(data).toEqual({});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reassignGareInHoraires (mode hors-ligne)', () => {
|
||||
it('devrait gérer la réassignation sans crash', async () => {
|
||||
const result = await reassignGareInHoraires('g-old', 'g-new', 'Nouvelle Gare');
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Substitutions (Consolidée dans la table unique horaires)', () => {
|
||||
describe('getAllSubstitutions', () => {
|
||||
it('devrait retourner un tableau vide sans erreur si non configuré', async () => {
|
||||
const { data, error } = await getAllSubstitutions();
|
||||
expect(data).toEqual([]);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubstitutionById', () => {
|
||||
it('devrait retourner une erreur Supabase non configuré', async () => {
|
||||
const { data, error } = await getSubstitutionById('sub-1');
|
||||
expect(data).toBeNull();
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSubstitution', () => {
|
||||
it('devrait créer une substitution avec le flag est_substitution=true', async () => {
|
||||
const payload = {
|
||||
horaire_id: 'train-123',
|
||||
numero_train: 'CAR-001',
|
||||
gare_depart_nom: 'Gare A',
|
||||
gare_arrivee_nom: 'Gare B'
|
||||
};
|
||||
const dessertes = [
|
||||
{ gare_nom: 'Gare A', heure_depart: '10:00' },
|
||||
{ gare_nom: 'Gare B', heure_arrivee: '11:00' }
|
||||
];
|
||||
|
||||
const { data, error } = await createSubstitution(payload, dessertes);
|
||||
expect(error).toBeNull();
|
||||
expect(data).toBeDefined();
|
||||
expect(data.est_substitution).toBe(true);
|
||||
expect(data.date_fin).toBeDefined();
|
||||
expect(data.gares_desservies).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,15 +5,29 @@ const isSupabaseConfigured = !!supabase;
|
||||
export async function getAllSubstitutions(regionId = null) {
|
||||
if (!isSupabaseConfigured) return { data: [], error: null };
|
||||
try {
|
||||
// Interroge en priorité la table unique consolidée 'horaires'
|
||||
let query = supabase
|
||||
.from('horaires_substitutions')
|
||||
.from('horaires')
|
||||
.select('*')
|
||||
.eq('est_substitution', true)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (regionId) query = query.eq('region_id', regionId);
|
||||
|
||||
const { data, error } = await query;
|
||||
return { data: data || [], error };
|
||||
if (error) {
|
||||
// Repli sur l'ancienne table si la colonne est_substitution n'est pas encore migrée
|
||||
let fallbackQuery = supabase
|
||||
.from('horaires_substitutions')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (regionId) fallbackQuery = fallbackQuery.eq('region_id', regionId);
|
||||
|
||||
const { data: fallbackData, error: fallbackError } = await fallbackQuery;
|
||||
return { data: fallbackData || [], error: fallbackError };
|
||||
}
|
||||
return { data: data || [], error: null };
|
||||
} catch (err) {
|
||||
console.error('Erreur getAllSubstitutions:', err);
|
||||
return { data: [], error: err };
|
||||
@@ -24,11 +38,22 @@ export async function getSubstitutionById(id) {
|
||||
if (!isSupabaseConfigured) return { data: null, error: { message: 'Supabase non configuré' } };
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('horaires')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('est_substitution', true)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
// Repli sur l'ancienne table
|
||||
const { data: legacyData, error: legacyError } = await supabase
|
||||
.from('horaires_substitutions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
if (error) return { data: null, error };
|
||||
if (legacyError) return { data: null, error: legacyError };
|
||||
return { data: legacyData, error: null };
|
||||
}
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
console.error('Erreur getSubstitutionById:', err);
|
||||
@@ -39,11 +64,10 @@ export async function getSubstitutionById(id) {
|
||||
export async function createSubstitution(payload, garesDesservies = []) {
|
||||
if (!isSupabaseConfigured) {
|
||||
const fakeId = `local-${Date.now()}`;
|
||||
// Ensure date_fin default to end of day for local mode
|
||||
const now = new Date();
|
||||
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).toISOString();
|
||||
const payloadWithDefaults = { ...payload, date_fin: payload.date_fin || endOfDay };
|
||||
return { data: { id: fakeId, ...payloadWithDefaults, gares_desservies: garesDesservies }, error: null };
|
||||
return { data: { id: fakeId, ...payloadWithDefaults, est_substitution: true, gares_desservies: garesDesservies }, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -51,27 +75,48 @@ export async function createSubstitution(payload, garesDesservies = []) {
|
||||
let regionToUse = payload.region_id ?? null;
|
||||
if (!regionToUse && payload && payload.horaire_id) {
|
||||
try {
|
||||
const { data: horaireData, error: horaireError } = await supabase
|
||||
const { data: horaireData } = await supabase
|
||||
.from('horaires')
|
||||
.select('region_id, region')
|
||||
.eq('id', payload.horaire_id)
|
||||
.single();
|
||||
if (!horaireError && horaireData) regionToUse = horaireData.region_id || horaireData.region || null;
|
||||
if (horaireData) regionToUse = horaireData.region_id || horaireData.region || null;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
// Définir date_fin à la fin de la journée si non fourni
|
||||
const now = new Date();
|
||||
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).toISOString();
|
||||
const payloadWithDefaults = { ...payload, region_id: regionToUse !== undefined ? regionToUse : payload.region_id, date_fin: payload.date_fin || endOfDay };
|
||||
const payloadWithDefaults = {
|
||||
...payload,
|
||||
region_id: regionToUse !== undefined ? regionToUse : payload.region_id,
|
||||
date_fin: payload.date_fin || endOfDay,
|
||||
est_substitution: true
|
||||
};
|
||||
const garesDesserviesJson = (garesDesservies || []).map((g, idx) => ({
|
||||
gare_id: g.gare_id || null,
|
||||
gare_nom: g.gare_nom || '',
|
||||
heure_arrivee: g.heure_arrivee || null,
|
||||
heure_depart: g.heure_depart || null,
|
||||
ordre: g.ordre ?? idx + 1
|
||||
ordre: g.ordre ?? idx + 1,
|
||||
quai: g.quai || null
|
||||
}));
|
||||
|
||||
// Insertion dans la table unique consolidée horaires
|
||||
const { data: horaire, error: horaireError } = await supabase
|
||||
.from('horaires')
|
||||
.insert([{
|
||||
...payloadWithDefaults,
|
||||
type_train: payloadWithDefaults.type_train || 'Autocar',
|
||||
gares_desservies: garesDesserviesJson,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}])
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (horaireError) {
|
||||
// Repli vers l'ancienne table si la colonne/table unique n'est pas disponible
|
||||
const { data: legacyData, error: legacyError } = await supabase
|
||||
.from('horaires_substitutions')
|
||||
.insert([{
|
||||
...payloadWithDefaults,
|
||||
@@ -82,12 +127,23 @@ export async function createSubstitution(payload, garesDesservies = []) {
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (horaireError) {
|
||||
console.error('Erreur création substitution:', horaireError);
|
||||
return { data: null, error: horaireError };
|
||||
if (legacyError) {
|
||||
console.error('Erreur création substitution:', legacyError);
|
||||
return { data: null, error: legacyError };
|
||||
}
|
||||
return { data: legacyData, error: null };
|
||||
}
|
||||
|
||||
// Si associé à un train parent, marquer substitution_disponible = true
|
||||
if (payload.horaire_id) {
|
||||
try {
|
||||
await supabase
|
||||
.from('horaires')
|
||||
.update({ substitution_disponible: true, updated_at: new Date().toISOString() })
|
||||
.eq('id', payload.horaire_id);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// Recharger l'objet avec ses relations si nécessaire
|
||||
return { data: horaire, error: null };
|
||||
} catch (err) {
|
||||
console.error('Exception createSubstitution:', err);
|
||||
@@ -104,24 +160,36 @@ export async function updateSubstitution(id, payload, garesDesservies = null) {
|
||||
gare_nom: g.gare_nom || '',
|
||||
heure_arrivee: g.heure_arrivee || null,
|
||||
heure_depart: g.heure_depart || null,
|
||||
ordre: g.ordre ?? idx + 1
|
||||
ordre: g.ordre ?? idx + 1,
|
||||
quai: g.quai || null
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const updatePayload = {
|
||||
...payload,
|
||||
updated_at: new Date(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...(garesDesserviesJson !== undefined ? { gares_desservies: garesDesserviesJson } : {})
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('horaires_substitutions')
|
||||
.from('horaires')
|
||||
.update(updatePayload)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return { data, error };
|
||||
if (error) {
|
||||
// Repli sur l'ancienne table
|
||||
const { data: legacyData, error: legacyError } = await supabase
|
||||
.from('horaires_substitutions')
|
||||
.update(updatePayload)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
return { data: legacyData, error: legacyError };
|
||||
}
|
||||
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
console.error('Erreur updateSubstitution:', err);
|
||||
return { data: null, error: err };
|
||||
@@ -132,10 +200,19 @@ export async function deleteSubstitution(id) {
|
||||
if (!isSupabaseConfigured) return { data: null, error: new Error('Supabase non configuré') };
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('horaires')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
// Repli sur l'ancienne table
|
||||
const { error: legacyError } = await supabase
|
||||
.from('horaires_substitutions')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
return { data: null, error };
|
||||
return { data: null, error: legacyError };
|
||||
}
|
||||
return { data: null, error: null };
|
||||
} catch (err) {
|
||||
console.error('Erreur deleteSubstitution:', err);
|
||||
return { data: null, error: err };
|
||||
|
||||
@@ -18,14 +18,19 @@ export async function getAllUtilisateurs() {
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Erreur getAllUtilisateurs:', error);
|
||||
return { data: null, error };
|
||||
console.warn('getAllUtilisateurs DB warning:', error?.message);
|
||||
// Fallback si la table utilisateurs n'est pas utilisée : renvoyer l'utilisateur connecté
|
||||
const local = window.localStorage.getItem('app_user');
|
||||
const currentUser = local ? JSON.parse(local) : null;
|
||||
return { data: currentUser ? [currentUser] : [], error: null };
|
||||
}
|
||||
|
||||
return { data, error: null };
|
||||
return { data: data || [], error: null };
|
||||
} catch (err) {
|
||||
console.error('Exception getAllUtilisateurs:', err);
|
||||
return { data: null, error: err };
|
||||
console.warn('Exception getAllUtilisateurs:', err);
|
||||
const local = window.localStorage.getItem('app_user');
|
||||
const currentUser = local ? JSON.parse(local) : null;
|
||||
return { data: currentUser ? [currentUser] : [], error: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,19 +68,46 @@ export async function createUtilisateur(userData) {
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Tentative d'enregistrement dans Supabase Auth (GoTrue) si un mot de passe est fourni
|
||||
try {
|
||||
if (userData.password) {
|
||||
await supabase.auth.signUp({
|
||||
email: payload.email,
|
||||
password: userData.password,
|
||||
options: {
|
||||
data: {
|
||||
nom: payload.nom,
|
||||
prenom: payload.prenom,
|
||||
name: `${payload.nom} ${payload.prenom}`.trim(),
|
||||
role: payload.role,
|
||||
permissions: payload.permissions,
|
||||
region_admin: payload.region_admin,
|
||||
region_id: payload.region_id,
|
||||
region_nom: payload.region_nom,
|
||||
photo_url: payload.photo_url,
|
||||
actif: payload.actif
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (authErr) {
|
||||
console.warn('[createUtilisateur] Supabase Auth signUp warning:', authErr);
|
||||
}
|
||||
|
||||
// Sauvegarde DB si la table utilisateurs existe
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('utilisateurs')
|
||||
.insert([payload])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
// Gestion spécifique des erreurs d'unicité d'email
|
||||
console.error('Erreur createUtilisateur:', error);
|
||||
return { data: null, error };
|
||||
}
|
||||
|
||||
if (!error && data) {
|
||||
return { data, error: null };
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return { data: { id: payload.email, ...payload }, error: null };
|
||||
} catch (err) {
|
||||
console.error('Exception createUtilisateur:', err);
|
||||
return { data: null, error: err };
|
||||
@@ -122,19 +154,47 @@ export async function updateUtilisateur(id, userData) {
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Synchronisation avec Supabase Auth (user_metadata)
|
||||
try {
|
||||
const { data: authUserData } = await supabase.auth.getUser();
|
||||
const authUser = authUserData?.user;
|
||||
if (authUser && (authUser.id === id || authUser.email?.toLowerCase() === payload.email)) {
|
||||
await supabase.auth.updateUser({
|
||||
...(userData.password ? { password: userData.password } : {}),
|
||||
data: {
|
||||
nom: payload.nom,
|
||||
prenom: payload.prenom,
|
||||
name: `${payload.nom} ${payload.prenom}`.trim(),
|
||||
role: payload.role,
|
||||
permissions: payload.permissions || {},
|
||||
region_admin: payload.region_admin,
|
||||
region_id: payload.region_id,
|
||||
region_nom: payload.region_nom,
|
||||
photo_url: payload.photo_url,
|
||||
actif: payload.actif
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (authErr) {
|
||||
console.warn('[updateUtilisateur] Sync Supabase Auth metadata warning:', authErr);
|
||||
}
|
||||
|
||||
// Mise à jour de la table DB s'il y a lieu
|
||||
let dbData = null;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('utilisateurs')
|
||||
.update(payload)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error('Erreur updateUtilisateur:', error);
|
||||
return { data: null, error };
|
||||
if (!error && data) {
|
||||
dbData = data;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return { data, error: null };
|
||||
return { data: dbData || { id, ...payload }, error: null };
|
||||
} catch (err) {
|
||||
console.error('Exception updateUtilisateur:', err);
|
||||
return { data: null, error: err };
|
||||
|
||||
@@ -237,14 +237,14 @@ export default function CirculationCalendar({ serviceAnnuel, selectedDays, onCha
|
||||
<div className="circulation-calendar-container">
|
||||
<div className="cal-templates">
|
||||
<span className="cal-templates-label">Sélection rapide :</span>
|
||||
<button type="button" className="cal-tpl-btn" onClick={() => applyTemplate('lun-ven')}>Lun à Ven</button>
|
||||
<button type="button" className="cal-tpl-btn" onClick={() => applyTemplate('sam')}>Sam</button>
|
||||
<button type="button" className="cal-tpl-btn" onClick={() => applyTemplate('dim')}>Dim</button>
|
||||
<button type="button" className="cal-tpl-btn" onClick={() => applyTemplate('dim-feries')}>Dim & Fériés</button>
|
||||
<button type="button" className="cal-tpl-btn" onClick={() => applyTemplate('sauf-sam')}>Sauf Samedi</button>
|
||||
<button type="button" className="cal-tpl-btn" onClick={() => applyTemplate('sauf-dim')}>Sauf Dimanche</button>
|
||||
<button type="button" className="cal-tpl-btn" onClick={() => applyTemplate('tous')}>Tous</button>
|
||||
<button type="button" className="cal-tpl-btn danger" onClick={() => applyTemplate('aucun')}>Aucun</button>
|
||||
<wcs-button mode="stroked" size="s" onClick={() => applyTemplate('lun-ven')}>Lun à Ven</wcs-button>
|
||||
<wcs-button mode="stroked" size="s" onClick={() => applyTemplate('sam')}>Sam</wcs-button>
|
||||
<wcs-button mode="stroked" size="s" onClick={() => applyTemplate('dim')}>Dim</wcs-button>
|
||||
<wcs-button mode="stroked" size="s" onClick={() => applyTemplate('dim-feries')}>Dim & Fériés</wcs-button>
|
||||
<wcs-button mode="stroked" size="s" onClick={() => applyTemplate('sauf-sam')}>Sauf Samedi</wcs-button>
|
||||
<wcs-button mode="stroked" size="s" onClick={() => applyTemplate('sauf-dim')}>Sauf Dimanche</wcs-button>
|
||||
<wcs-button mode="stroked" size="s" onClick={() => applyTemplate('tous')}>Tous</wcs-button>
|
||||
<wcs-button mode="stroked" size="s" className="wcs-danger" onClick={() => applyTemplate('aucun')}>Aucun</wcs-button>
|
||||
</div>
|
||||
|
||||
<div className="cal-scroll-area">
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
/* ==========================================================================
|
||||
SNCF DESIGN SYSTEM - HORAIRE MODAL (CREATION & MODIFICATION DE SILLONS)
|
||||
========================================================================== */
|
||||
|
||||
.horaire-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1050;
|
||||
padding: 16px;
|
||||
animation: modalFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.horaire-modal-container {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
width: 100%;
|
||||
max-width: 980px;
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 25px 60px -15px rgba(0, 25, 50, 0.45), 0 0 0 1px rgba(0, 136, 206, 0.15);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Header SNCF */
|
||||
.horaire-sncf-header {
|
||||
background: linear-gradient(135deg, #001932 0%, #0d3d67 60%, #0088ce 140%);
|
||||
color: #ffffff;
|
||||
padding: 20px 26px 18px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.horaire-header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.horaire-header-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #7dd3fc;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.horaire-header-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.horaire-header-title {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Summary Journey Bar */
|
||||
.horaire-journey-summary {
|
||||
background: linear-gradient(180deg, #f0f7ff 0%, #e6f2fc 100%);
|
||||
border-bottom: 1px solid #bfdbfe;
|
||||
padding: 14px 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.horaire-journey-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.horaire-journey-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.horaire-journey-node-tag {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.horaire-journey-station {
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.horaire-journey-time {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #0088ce;
|
||||
background: rgba(0, 136, 206, 0.1);
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.horaire-journey-line-segment {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.horaire-journey-line-bar {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: #94a3b8;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.horaire-journey-duration-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #0369a1;
|
||||
background: #ffffff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #bae6fd;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.horaire-journey-meta-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* WCS SNCF Tabs Container */
|
||||
.horaire-tabs-container {
|
||||
background: #f8fafc;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
padding: 0 20px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.horaire-wcs-tabs-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
border-bottom: none;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.horaire-wcs-tabs-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.horaire-tab-btn.wcs-tab-header {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
padding: 13px 18px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
margin-bottom: -2px;
|
||||
position: relative;
|
||||
font-family: inherit;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.horaire-tab-btn.wcs-tab-header:hover {
|
||||
color: #0088ce;
|
||||
background: rgba(0, 136, 206, 0.05);
|
||||
}
|
||||
|
||||
.horaire-tab-btn.wcs-tab-header.active {
|
||||
color: #0088ce;
|
||||
border-bottom-color: #0088ce;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.horaire-tab-btn.wcs-tab-header.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background-color: #0088ce;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.horaire-tab-text {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Modal Body Scroll */
|
||||
.horaire-modal-content-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Cards WCS */
|
||||
.horaire-modal-container wcs-card {
|
||||
display: block;
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.horaire-modal-container wcs-card-body {
|
||||
display: block;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.horaire-section-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
color: #001932;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* Form Fields & Grids */
|
||||
.horaire-grid-2cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.horaire-modal-container wcs-form-field {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.horaire-modal-container wcs-input,
|
||||
.horaire-modal-container wcs-select {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Autocomplete Gare */
|
||||
.horaire-autocomplete-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.horaire-autocomplete-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.horaire-autocomplete-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.horaire-autocomplete-item:hover {
|
||||
background: #eff6ff;
|
||||
color: #0088ce;
|
||||
}
|
||||
|
||||
/* Arrêts intermédiaires WCS card */
|
||||
.horaire-stop-card {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.horaire-stop-card-inner {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.horaire-stop-reorder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.horaire-stop-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.horaire-stop-fields wcs-form-field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Track Composition in Modal */
|
||||
.horaire-compo-track {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 12px;
|
||||
padding: 18px 16px;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.horaire-compo-rails {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.horaire-compo-elements {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.horaire-compo-wagon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.horaire-compo-wagon:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.horaire-compo-wagon.is-drag-over {
|
||||
border-color: #0088ce;
|
||||
background: rgba(0, 136, 206, 0.2);
|
||||
}
|
||||
|
||||
/* Materiels catalog grid */
|
||||
.horaire-materiels-catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.horaire-materiel-card {
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.horaire-materiel-card:hover:not(.is-occupied) {
|
||||
border-color: #0088ce;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 136, 206, 0.15);
|
||||
}
|
||||
|
||||
.horaire-materiel-card.is-occupied {
|
||||
opacity: 0.65;
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.horaire-materiel-card.is-in-compo {
|
||||
border-color: #0088ce;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Modal Footer */
|
||||
.horaire-modal-footer {
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 16px 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.horaire-footer-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.horaire-footer-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #cbd5e1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.horaire-footer-dot.is-active {
|
||||
background: #0088ce;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.horaire-footer-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Mode sombre */
|
||||
body.dark-mode .horaire-modal-container,
|
||||
[data-theme="dark"] .horaire-modal-container {
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-journey-summary,
|
||||
[data-theme="dark"] .horaire-journey-summary {
|
||||
background: #1e293b;
|
||||
border-bottom-color: #334155;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-journey-station,
|
||||
[data-theme="dark"] .horaire-journey-station {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-tabs-container,
|
||||
[data-theme="dark"] .horaire-tabs-container {
|
||||
background: #1e293b;
|
||||
border-bottom-color: #334155;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-tab-btn.wcs-tab-header,
|
||||
[data-theme="dark"] .horaire-tab-btn.wcs-tab-header {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-tab-btn.wcs-tab-header:hover,
|
||||
[data-theme="dark"] .horaire-tab-btn.wcs-tab-header:hover {
|
||||
color: #38bdf8;
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-tab-btn.wcs-tab-header.active,
|
||||
[data-theme="dark"] .horaire-tab-btn.wcs-tab-header.active {
|
||||
color: #38bdf8;
|
||||
border-bottom-color: #38bdf8;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-tab-btn.wcs-tab-header.active::after,
|
||||
[data-theme="dark"] .horaire-tab-btn.wcs-tab-header.active::after {
|
||||
background-color: #38bdf8;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-modal-content-scroll,
|
||||
[data-theme="dark"] .horaire-modal-content-scroll {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-modal-container wcs-card,
|
||||
[data-theme="dark"] .horaire-modal-container wcs-card {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-section-card-title,
|
||||
[data-theme="dark"] .horaire-section-card-title {
|
||||
color: #f8fafc;
|
||||
border-bottom-color: #334155;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-autocomplete-dropdown,
|
||||
[data-theme="dark"] .horaire-autocomplete-dropdown {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-autocomplete-item,
|
||||
[data-theme="dark"] .horaire-autocomplete-item {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-autocomplete-item:hover,
|
||||
[data-theme="dark"] .horaire-autocomplete-item:hover {
|
||||
background: #334155;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-materiel-card,
|
||||
[data-theme="dark"] .horaire-materiel-card {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
body.dark-mode .horaire-modal-footer,
|
||||
[data-theme="dark"] .horaire-modal-footer {
|
||||
background: #1e293b;
|
||||
border-top-color: #334155;
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.horaire-grid-2cols,
|
||||
.horaire-stop-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.horaire-stop-card-inner {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.horaire-journey-summary {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import HoraireModal from './HoraireModal';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../api/horaires', () => ({
|
||||
createHoraire: jest.fn(),
|
||||
updateHoraire: jest.fn()
|
||||
}));
|
||||
|
||||
describe('HoraireModal (WCS SNCF Design System)', () => {
|
||||
const mockGares = [
|
||||
{ id: 1, nom: 'Paris Gare de Lyon' },
|
||||
{ id: 2, nom: 'Lyon Part-Dieu' },
|
||||
{ id: 3, nom: 'Marseille Saint-Charles' }
|
||||
];
|
||||
|
||||
const mockTypesTrains = [
|
||||
{ id: 1, nom: 'TGV INOUI' },
|
||||
{ id: 2, nom: 'TER' }
|
||||
];
|
||||
|
||||
const mockLignes = [
|
||||
{ id: 1, nom: 'Paris - Lyon - Marseille', code: 'PLM' }
|
||||
];
|
||||
|
||||
const mockServicesAnnuels = [
|
||||
{ id: 1, nom: 'SA 2026', date_debut: '2025-12-14', date_fin: '2026-12-12' }
|
||||
];
|
||||
|
||||
it('devrait rendre la modale avec les composants WCS de navigation et les selects autocomplete', () => {
|
||||
const { container } = render(
|
||||
<HoraireModal
|
||||
onClose={jest.fn()}
|
||||
allGares={mockGares}
|
||||
typesTrainsList={mockTypesTrains}
|
||||
lignes={mockLignes}
|
||||
servicesAnnuels={mockServicesAnnuels}
|
||||
canCreateHoraires={true}
|
||||
canUpdateHoraires={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Vérifier la présence des onglets WCS SNCF affichés
|
||||
const tabHeaders = container.querySelectorAll('.wcs-tab-header');
|
||||
expect(tabHeaders.length).toBe(5);
|
||||
expect(container.querySelector('.wcs-tabs-headers')).toBeInTheDocument();
|
||||
expect(container.querySelector('wcs-tabs')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('wcs-tab').length).toBe(5);
|
||||
|
||||
// Vérifier les champs de recherche autocomplete
|
||||
const autocompleteSelects = container.querySelectorAll('wcs-select#select-autocomplete');
|
||||
expect(autocompleteSelects.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Vérifier spécifiquement type de train, gare départ et gare arrivée
|
||||
expect(container.querySelector('wcs-select[data-field="typeTrainId"]#select-autocomplete')).toBeInTheDocument();
|
||||
expect(container.querySelector('wcs-select[data-field="gareDepartId"]#select-autocomplete')).toBeInTheDocument();
|
||||
expect(container.querySelector('wcs-select[data-field="gareArriveeId"]#select-autocomplete')).toBeInTheDocument();
|
||||
|
||||
// Vérifier numéro de train et heures
|
||||
expect(container.querySelector('wcs-input[data-field="numeroTrain"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('wcs-input[data-field="heureDepart"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('wcs-input[data-field="heureArrivee"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('devrait permettre de naviguer entre les onglets du formulaire', () => {
|
||||
const { container } = render(
|
||||
<HoraireModal
|
||||
onClose={jest.fn()}
|
||||
allGares={mockGares}
|
||||
typesTrainsList={mockTypesTrains}
|
||||
lignes={mockLignes}
|
||||
servicesAnnuels={mockServicesAnnuels}
|
||||
canCreateHoraires={true}
|
||||
canUpdateHoraires={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Au départ, l'onglet Général est actif
|
||||
const tabs = container.querySelectorAll('.wcs-tab-header');
|
||||
expect(tabs[0]).toHaveClass('active');
|
||||
expect(screen.getByText('Identification du Train & Référentiel')).toBeInTheDocument();
|
||||
|
||||
// Cliquer sur le 2ème onglet (Gares desservies)
|
||||
fireEvent.click(tabs[1]);
|
||||
expect(tabs[1]).toHaveClass('active');
|
||||
expect(screen.getByText('Arrêts intermédiaires desservis')).toBeInTheDocument();
|
||||
|
||||
// Cliquer sur le 5ème onglet (Substitution)
|
||||
fireEvent.click(tabs[4]);
|
||||
expect(tabs[4]).toHaveClass('active');
|
||||
expect(screen.getByText('Remplacement & Substitution Routière')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('devrait initialiser les champs et les selects autocomplete à partir des données de départ', () => {
|
||||
const initialData = {
|
||||
id: 42,
|
||||
numero_train: 'TGV 6601',
|
||||
gare_depart_id: '1',
|
||||
gare_depart_nom: 'Paris Gare de Lyon',
|
||||
heure_depart: '08:00',
|
||||
gare_arrivee_id: '2',
|
||||
gare_arrivee_nom: 'Lyon Part-Dieu',
|
||||
heure_arrivee: '10:00',
|
||||
type_train_id: '1',
|
||||
type_train: 'TGV INOUI',
|
||||
ligne_id: '1',
|
||||
service_annuel_id: '1'
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<HoraireModal
|
||||
initialData={initialData}
|
||||
onClose={jest.fn()}
|
||||
allGares={mockGares}
|
||||
typesTrainsList={mockTypesTrains}
|
||||
lignes={mockLignes}
|
||||
servicesAnnuels={mockServicesAnnuels}
|
||||
canCreateHoraires={true}
|
||||
canUpdateHoraires={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const numInput = container.querySelector('wcs-input[data-field="numeroTrain"]');
|
||||
expect(numInput).toHaveAttribute('value', 'TGV 6601');
|
||||
|
||||
const gareDepSelect = container.querySelector('wcs-select[data-field="gareDepartId"]');
|
||||
expect(gareDepSelect).toHaveAttribute('value', '1');
|
||||
|
||||
const gareArrSelect = container.querySelector('wcs-select[data-field="gareArriveeId"]');
|
||||
expect(gareArrSelect).toHaveAttribute('value', '2');
|
||||
|
||||
const typeSelect = container.querySelector('wcs-select[data-field="typeTrainId"]');
|
||||
expect(typeSelect).toHaveAttribute('value', '1');
|
||||
|
||||
const hDepInput = container.querySelector('wcs-input[data-field="heureDepart"]');
|
||||
expect(hDepInput).toHaveAttribute('value', '08:00');
|
||||
|
||||
const hArrInput = container.querySelector('wcs-input[data-field="heureArrivee"]');
|
||||
expect(hArrInput).toHaveAttribute('value', '10:00');
|
||||
});
|
||||
|
||||
it('devrait déclencher onSaveOverride lors de la soumission si fourni', async () => {
|
||||
const handleSaveOverride = jest.fn();
|
||||
const initialData = {
|
||||
id: 99,
|
||||
numero_train: 'TER 8801',
|
||||
gare_depart_id: '1',
|
||||
gare_depart_nom: 'Paris Gare de Lyon',
|
||||
heure_depart: '09:00',
|
||||
gare_arrivee_id: '2',
|
||||
gare_arrivee_nom: 'Lyon Part-Dieu',
|
||||
heure_arrivee: '11:00'
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<HoraireModal
|
||||
initialData={initialData}
|
||||
onClose={jest.fn()}
|
||||
allGares={mockGares}
|
||||
typesTrainsList={mockTypesTrains}
|
||||
lignes={mockLignes}
|
||||
servicesAnnuels={mockServicesAnnuels}
|
||||
canCreateHoraires={true}
|
||||
canUpdateHoraires={true}
|
||||
onSaveOverride={handleSaveOverride}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitBtn = container.querySelector('wcs-button.wcs-primary');
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
expect(handleSaveOverride).toHaveBeenCalledTimes(1);
|
||||
expect(handleSaveOverride).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
numero_train: 'TER 8801',
|
||||
gare_depart_id: '1',
|
||||
gare_arrivee_id: '2',
|
||||
heure_depart: '09:00',
|
||||
heure_arrivee: '11:00'
|
||||
}),
|
||||
expect.any(Array)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2012,5 +2012,313 @@ body.dark-mode .gtfs-accordion-trip-row.selected,
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
SYSTÈME DE RÉSOLUTION DES DONNÉES MANQUANTES AVANT IMPORT (PRÉ-ENREGISTREMENT)
|
||||
============================================================================ */
|
||||
|
||||
.missing-entities-hero {
|
||||
background: linear-gradient(135deg, #1e3a8a, #3b82f6);
|
||||
border-radius: 14px;
|
||||
padding: 22px 26px;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.missing-entities-hero.all-resolved {
|
||||
background: linear-gradient(135deg, #15803d, #22c55e);
|
||||
box-shadow: 0 8px 24px rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
.missing-entities-hero-content h4 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.missing-entities-hero-content p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
opacity: 0.92;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.missing-counts-pill-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.missing-count-pill {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.missing-count-pill.resolved {
|
||||
background: rgba(34, 197, 94, 0.25);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.btn-magic-resolve {
|
||||
background: #ffffff;
|
||||
color: #1e3a8a;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 12px 22px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-magic-resolve:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.btn-magic-resolve:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Onglets de résolution */
|
||||
.missing-tabs-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
margin-bottom: 20px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.missing-tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.missing-tab-btn:hover {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.missing-tab-btn.active {
|
||||
color: #2563eb;
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
.missing-tab-badge {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.missing-tab-badge.zero {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.missing-action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-quick-create {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-quick-create:hover:not(:disabled) {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-batch-create {
|
||||
background: #0ea5e9;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-batch-create:hover:not(:disabled) {
|
||||
background: #0284c7;
|
||||
}
|
||||
|
||||
.mapping-select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
background-color: #ffffff;
|
||||
color: #1e293b;
|
||||
max-width: 260px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mapping-select:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.status-badge-created {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #15803d;
|
||||
background: #dcfce7;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge-missing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #b91c1c;
|
||||
background: #fee2e2;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge-mapped {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #0369a1;
|
||||
background: #e0f2fe;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Barre de progression d'import en direct */
|
||||
.import-live-progress {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.import-live-progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.import-progress-bar-bg {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.import-progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2563eb, #38bdf8);
|
||||
border-radius: 6px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.import-live-train-info {
|
||||
margin-top: 10px;
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
body.dark-mode .missing-action-bar,
|
||||
[data-theme="dark"] .missing-action-bar {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
body.dark-mode .mapping-select,
|
||||
[data-theme="dark"] .mapping-select {
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
body.dark-mode .import-live-progress,
|
||||
[data-theme="dark"] .import-live-progress {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tests unitaires pour la logique de pré-résolution et d'importation d'horaires
|
||||
*/
|
||||
|
||||
describe('Système de détection et de pré-résolution d\'entités manquantes avant import', () => {
|
||||
const mockGares = [
|
||||
{ id: 'g-paris', nom: 'Paris Gare de Lyon' },
|
||||
{ id: 'g-lyon', nom: 'Lyon Part-Dieu' }
|
||||
];
|
||||
|
||||
const mockTypes = [
|
||||
{ id: 't-tgv', nom: 'TGV INOUI' },
|
||||
{ id: 't-ter', nom: 'TER' }
|
||||
];
|
||||
|
||||
const mockLignes = [
|
||||
{ id: 'l-plm', nom: 'Ligne Paris - Marseille' }
|
||||
];
|
||||
|
||||
const mockServices = [
|
||||
{ id: 'sa-2026', nom: 'SA 2026', date_debut: '2025-12-14', date_fin: '2026-12-12' }
|
||||
];
|
||||
|
||||
test('Détection correcte des gares, types et lignes manquants dans un fichier d\'import', () => {
|
||||
const rows = [
|
||||
{
|
||||
numero_train: '6601',
|
||||
gare_depart_nom: 'Paris Gare de Lyon',
|
||||
gare_arrivee_nom: 'Marseille Saint-Charles', // Manquante
|
||||
type_train: 'TGV INOUI',
|
||||
ligne_nom: 'Ligne Paris - Marseille',
|
||||
gares_desservies: JSON.stringify([
|
||||
{ gare_nom: 'Lyon Part-Dieu' },
|
||||
{ gare_nom: 'Avignon TGV' } // Manquante
|
||||
])
|
||||
},
|
||||
{
|
||||
numero_train: '8501',
|
||||
gare_depart_nom: 'Marseille Saint-Charles',
|
||||
gare_arrivee_nom: 'Nice Ville', // Manquante
|
||||
type_train: 'OUIGO', // Manquant
|
||||
ligne_nom: 'Ligne Côte d\'Azur' // Manquante
|
||||
}
|
||||
];
|
||||
|
||||
// Simulation de la détection
|
||||
const missingGares = new Set();
|
||||
const missingTypes = new Set();
|
||||
const missingLignes = new Set();
|
||||
|
||||
const garesMap = new Set(mockGares.map(g => g.nom.toLowerCase()));
|
||||
const typesMap = new Set(mockTypes.map(t => t.nom.toLowerCase()));
|
||||
const lignesMap = new Set(mockLignes.map(l => l.nom.toLowerCase()));
|
||||
|
||||
rows.forEach(r => {
|
||||
if (r.gare_depart_nom && !garesMap.has(r.gare_depart_nom.toLowerCase())) missingGares.add(r.gare_depart_nom);
|
||||
if (r.gare_arrivee_nom && !garesMap.has(r.gare_arrivee_nom.toLowerCase())) missingGares.add(r.gare_arrivee_nom);
|
||||
if (r.type_train && !typesMap.has(r.type_train.toLowerCase())) missingTypes.add(r.type_train);
|
||||
if (r.ligne_nom && !lignesMap.has(r.ligne_nom.toLowerCase())) missingLignes.add(r.ligne_nom);
|
||||
if (r.gares_desservies) {
|
||||
JSON.parse(r.gares_desservies).forEach(s => {
|
||||
if (s.gare_nom && !garesMap.has(s.gare_nom.toLowerCase())) missingGares.add(s.gare_nom);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
expect(Array.from(missingGares)).toEqual(['Marseille Saint-Charles', 'Avignon TGV', 'Nice Ville']);
|
||||
expect(Array.from(missingTypes)).toEqual(['OUIGO']);
|
||||
expect(Array.from(missingLignes)).toEqual(['Ligne Côte d\'Azur']);
|
||||
});
|
||||
|
||||
test('Propagation immédiate des IDs nouvellement créés dans les lignes importées', () => {
|
||||
let rows = [
|
||||
{
|
||||
numero_train: '6601',
|
||||
gare_depart_id: null,
|
||||
gare_depart_nom: 'Marseille Saint-Charles',
|
||||
gare_arrivee_id: null,
|
||||
gare_arrivee_nom: 'Paris Gare de Lyon',
|
||||
type_train_id: null,
|
||||
type_train: 'OUIGO',
|
||||
ligne_id: null,
|
||||
ligne_nom: 'Ligne Côte d\'Azur',
|
||||
gares_desservies: [
|
||||
{ gare_id: null, gare_nom: 'Avignon TGV', heure_arrivee: '10:00', heure_depart: '10:02' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 1. Simulation création de la gare 'Marseille Saint-Charles'
|
||||
const newMarseilleId = 'g-marseille-uuid';
|
||||
rows = rows.map(r => {
|
||||
const isDep = r.gare_depart_nom === 'Marseille Saint-Charles';
|
||||
const isArr = r.gare_arrivee_nom === 'Marseille Saint-Charles';
|
||||
return {
|
||||
...r,
|
||||
...(isDep ? { gare_depart_id: newMarseilleId } : {}),
|
||||
...(isArr ? { gare_arrivee_id: newMarseilleId } : {})
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Simulation création du type de train 'OUIGO'
|
||||
const newOuigoId = 't-ouigo-uuid';
|
||||
rows = rows.map(r => {
|
||||
if (r.type_train === 'OUIGO') {
|
||||
return { ...r, type_train_id: newOuigoId };
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
// 3. Simulation création de la ligne 'Ligne Côte d\'Azur'
|
||||
const newLigneId = 'l-cda-uuid';
|
||||
rows = rows.map(r => {
|
||||
if (r.ligne_nom === 'Ligne Côte d\'Azur') {
|
||||
return { ...r, ligne_id: newLigneId };
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
// 4. Simulation création de l'arrêt intermédiaire 'Avignon TGV'
|
||||
const newAvignonId = 'g-avignon-uuid';
|
||||
rows = rows.map(r => ({
|
||||
...r,
|
||||
gares_desservies: r.gares_desservies.map(s => (s.gare_nom === 'Avignon TGV' ? { ...s, gare_id: newAvignonId } : s))
|
||||
}));
|
||||
|
||||
expect(rows[0].gare_depart_id).toBe('g-marseille-uuid');
|
||||
expect(rows[0].type_train_id).toBe('t-ouigo-uuid');
|
||||
expect(rows[0].ligne_id).toBe('l-cda-uuid');
|
||||
expect(rows[0].gares_desservies[0].gare_id).toBe('g-avignon-uuid');
|
||||
});
|
||||
});
|
||||
@@ -150,6 +150,14 @@
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-login-separator {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-login-link {
|
||||
|
||||
+48
-21
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import supabase, { isSupabaseConfigured } from '../../supabaseClient';
|
||||
import { buildUserProfileFromAuth } from '../../utils/userUtils';
|
||||
import './Login.css';
|
||||
|
||||
|
||||
@@ -60,39 +61,53 @@ export default function Login({ onLogin }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer le profil métier depuis la table utilisateurs
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
// Récupération facultative du profil métier depuis la table 'utilisateurs' s'il existe
|
||||
let dbProfile = null;
|
||||
try {
|
||||
const { data: profile } = await supabase
|
||||
.from('utilisateurs')
|
||||
.select('id, email, nom, prenom, role, actif, region_admin, region_id, region_nom, permissions')
|
||||
.select('id, email, nom, prenom, role, actif, region_admin, region_id, region_nom, permissions, photo_url')
|
||||
.eq('email', email)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError || !profile) {
|
||||
setError("Profil utilisateur introuvable dans la base de données.");
|
||||
await supabase.auth.signOut();
|
||||
setLoading(false);
|
||||
return;
|
||||
if (profile) {
|
||||
dbProfile = profile;
|
||||
}
|
||||
} catch (e) {
|
||||
// En l'absence de table ou erreur RLS, les métadonnées Supabase Auth prennent le relais
|
||||
console.warn('[Login] Profil DB non disponible, utilisation des métadonnées Supabase Auth');
|
||||
}
|
||||
|
||||
if (profile.actif === false) {
|
||||
// Construit le profil et les permissions directement depuis Supabase Auth
|
||||
const user = buildUserProfileFromAuth(authData.user, dbProfile);
|
||||
|
||||
if (user.actif === false) {
|
||||
setError("Ce compte est désactivé.");
|
||||
await supabase.auth.signOut();
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
nom: profile.nom,
|
||||
prenom: profile.prenom,
|
||||
name: [profile.nom, profile.prenom].filter(Boolean).join(' ').trim() || profile.email,
|
||||
role: profile.role,
|
||||
permissions: profile.permissions || {},
|
||||
region_admin: profile.region_admin === true,
|
||||
region_id: profile.region_id || null,
|
||||
region_nom: profile.region_nom || null
|
||||
};
|
||||
// Synchroniser le profil dans user_metadata de Supabase Auth si absent pour pérennité sans table
|
||||
try {
|
||||
const currentMeta = authData.user.user_metadata || {};
|
||||
if (!currentMeta.role || !currentMeta.permissions) {
|
||||
await supabase.auth.updateUser({
|
||||
data: {
|
||||
role: user.role,
|
||||
permissions: user.permissions,
|
||||
nom: user.nom,
|
||||
prenom: user.prenom,
|
||||
name: user.name,
|
||||
region_admin: user.region_admin,
|
||||
region_id: user.region_id,
|
||||
region_nom: user.region_nom
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (metaErr) {
|
||||
// Ignorer silencieusement si échec de mise à jour des métadonnées
|
||||
}
|
||||
|
||||
try { window.localStorage.setItem('app_user', JSON.stringify(user)); } catch (_) {}
|
||||
onLogin(user);
|
||||
@@ -168,6 +183,18 @@ export default function Login({ onLogin }) {
|
||||
</div>
|
||||
|
||||
<div className="admin-login-shortcuts">
|
||||
<a
|
||||
className="admin-login-link"
|
||||
href="/reinitialiser-mot-de-passe"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const val = emailRef.current?.value ? String(emailRef.current.value).trim() : '';
|
||||
window.location.href = val ? `/reinitialiser-mot-de-passe?email=${encodeURIComponent(val)}` : '/reinitialiser-mot-de-passe';
|
||||
}}
|
||||
>
|
||||
Mot de passe oublié ?
|
||||
</a>
|
||||
<span className="admin-login-separator">•</span>
|
||||
<a className="admin-login-link" href="/activer-compte">Activer mon Compte</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* Styles pour la page de réinitialisation de mot de passe */
|
||||
.reset-password-shell {
|
||||
grid-template-columns: 1fr minmax(380px, 480px);
|
||||
}
|
||||
|
||||
.reset-password-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.reset-password-info-card {
|
||||
border: 1px solid #d9e2ec;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
background: #f8fbff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reset-password-info-card h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.reset-password-info-card p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.reset-password-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reset-password-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reset-password-confirmation {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.reset-password-countdown {
|
||||
font-size: 14px;
|
||||
color: #5a6577;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.reset-password-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
body.dark-mode .reset-password-info-card,
|
||||
[data-theme="dark"] .reset-password-info-card {
|
||||
background: #0e1726;
|
||||
border-color: #243247;
|
||||
}
|
||||
|
||||
body.dark-mode .reset-password-info-card h3,
|
||||
[data-theme="dark"] .reset-password-info-card h3 {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
body.dark-mode .reset-password-info-card p,
|
||||
[data-theme="dark"] .reset-password-info-card p {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import supabase, { isSupabaseConfigured } from '../../supabaseClient';
|
||||
import '../login/Login.css';
|
||||
import './ReinitialiserMotDePasse.css';
|
||||
|
||||
export default function ReinitialiserMotDePasse() {
|
||||
const emailRef = useRef(null);
|
||||
const passwordRef = useRef(null);
|
||||
const confirmPasswordRef = useRef(null);
|
||||
|
||||
// Étape du flow :
|
||||
// 'request': formulaire d'envoi du mail de réinitialisation
|
||||
// 'sent': email envoyé, confirmation affichée
|
||||
// 'reset': saisie du nouveau mot de passe (suite au clic sur le lien dans l'email)
|
||||
// 'success': mot de passe mis à jour avec succès
|
||||
const [mode, setMode] = useState('request');
|
||||
const [targetEmail, setTargetEmail] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
|
||||
// Détection de l'accès via le lien de réinitialisation (recovery token ou session)
|
||||
useEffect(() => {
|
||||
// 1. Vérifier si l'URL contient un hash de récupération (#access_token=...&type=recovery)
|
||||
const hash = window.location.hash || '';
|
||||
const search = window.location.search || '';
|
||||
|
||||
const isRecoveryHash = hash.includes('type=recovery') || hash.includes('access_token=');
|
||||
const isRecoverySearch = search.includes('type=recovery');
|
||||
|
||||
if (isRecoveryHash || isRecoverySearch) {
|
||||
setMode('reset');
|
||||
}
|
||||
|
||||
// 2. Écouter les événements Auth de Supabase (ex: PASSWORD_RECOVERY)
|
||||
const { data: authListener } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (event === 'PASSWORD_RECOVERY') {
|
||||
setMode('reset');
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Préremplir l'email si passé en paramètre d'URL (?email=...)
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const emailParam = params.get('email');
|
||||
if (emailParam) {
|
||||
setTargetEmail(emailParam);
|
||||
if (emailRef.current) {
|
||||
emailRef.current.value = emailParam;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return () => {
|
||||
if (authListener?.subscription) {
|
||||
authListener.subscription.unsubscribe();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Compte à rebours avant redirection après mise à jour réussie
|
||||
useEffect(() => {
|
||||
if (mode !== 'success') return undefined;
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
window.clearInterval(intervalId);
|
||||
window.location.assign('/');
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [mode]);
|
||||
|
||||
// Envoi de l'email de réinitialisation via Supabase
|
||||
const handleRequestReset = async (e) => {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
const email = emailRef.current ? String(emailRef.current.value || '').trim().toLowerCase() : targetEmail.trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
setError('Veuillez renseigner votre adresse email.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSupabaseConfigured) {
|
||||
setError('Supabase n\'est pas configuré. Impossible d\'envoyer le mail de réinitialisation.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const redirectTo = `${window.location.origin}/reinitialiser-mot-de-passe`;
|
||||
|
||||
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo
|
||||
});
|
||||
|
||||
if (resetError) {
|
||||
setError(`Erreur lors de l'envoi de l'email : ${resetError.message}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTargetEmail(email);
|
||||
setMode('sent');
|
||||
setSuccess(`Un email contenant un lien de réinitialisation a été envoyé à ${email}.`);
|
||||
} catch (err) {
|
||||
setError(`Erreur inattendue : ${err?.message || 'inconnue'}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mise à jour effective du mot de passe
|
||||
const handleUpdatePassword = async (e) => {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
const password = passwordRef.current ? String(passwordRef.current.value || '') : '';
|
||||
const confirmPassword = confirmPasswordRef.current ? String(confirmPasswordRef.current.value || '') : '';
|
||||
|
||||
if (!password || !confirmPassword) {
|
||||
setError('Veuillez saisir et confirmer votre nouveau mot de passe.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Les mots de passe ne correspondent pas.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Le mot de passe doit comporter au moins 8 caractères.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { data, error: updateError } = await supabase.auth.updateUser({
|
||||
password: password
|
||||
});
|
||||
|
||||
if (updateError) {
|
||||
setError(`Erreur lors de la mise à jour du mot de passe : ${updateError.message}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mot de passe mis à jour avec succès
|
||||
setMode('success');
|
||||
setCountdown(5);
|
||||
} catch (err) {
|
||||
setError(`Erreur inattendue : ${err?.message || 'inconnue'}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sncf-groupe admin-login-page">
|
||||
<div className="admin-login-shell reset-password-shell">
|
||||
<aside className="admin-login-branding">
|
||||
<span className="admin-login-kicker">Sécurité</span>
|
||||
<h1>Réinitialisation du mot de passe</h1>
|
||||
<p>
|
||||
Accédez à la console de gestion des exploitations en toute sécurité grâce
|
||||
à votre adresse email professionnelle.
|
||||
</p>
|
||||
<div className="admin-login-meta">
|
||||
<wcs-mat-icon icon="lock"></wcs-mat-icon>
|
||||
<span>Lien sécurisé et temporaire</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="admin-login-panel reset-password-panel" aria-labelledby="reset-password-title">
|
||||
<header className="admin-login-panel-header">
|
||||
<h2 id="reset-password-title">
|
||||
{mode === 'reset' && 'Définir un nouveau mot de passe'}
|
||||
{mode === 'sent' && 'Email envoyé'}
|
||||
{mode === 'success' && 'Mot de passe modifié'}
|
||||
{mode === 'request' && 'Mot de passe oublié ?'}
|
||||
</h2>
|
||||
<p>
|
||||
{mode === 'reset' && 'Saisissez votre nouveau mot de passe sécurisé pour finaliser la procédure.'}
|
||||
{mode === 'sent' && 'Vérifiez votre boîte de réception pour continuer.'}
|
||||
{mode === 'success' && 'Votre nouveau mot de passe est immédiatement actif.'}
|
||||
{mode === 'request' && 'Saisissez votre adresse email pour recevoir un lien de réinitialisation.'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Mode 1 : Demande d'envoi du lien */}
|
||||
{mode === 'request' && (
|
||||
<form onSubmit={handleRequestReset}>
|
||||
<div className="admin-login-form">
|
||||
<label htmlFor="reset-email">Email professionnel</label>
|
||||
<wcs-input
|
||||
id="reset-email"
|
||||
ref={emailRef}
|
||||
value={targetEmail}
|
||||
placeholder="nom@exemple.com"
|
||||
name="email"
|
||||
></wcs-input>
|
||||
|
||||
{error && (
|
||||
<wcs-alert intent="error" closable timeout={4000}>
|
||||
<span slot="title">Erreur</span>
|
||||
<span slot="subtitle">{error}</span>
|
||||
</wcs-alert>
|
||||
)}
|
||||
|
||||
<div className="reset-password-actions">
|
||||
<div className="reset-password-buttons">
|
||||
<wcs-button
|
||||
className="wcs-primary"
|
||||
type="submit"
|
||||
onClick={handleRequestReset}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Envoi en cours...' : 'Envoyer le lien'}
|
||||
</wcs-button>
|
||||
<wcs-button
|
||||
className="wcs-ghost"
|
||||
type="button"
|
||||
onClick={() => window.location.assign('/')}
|
||||
>
|
||||
Retour
|
||||
</wcs-button>
|
||||
</div>
|
||||
<div className="admin-login-spinner">
|
||||
{loading && <wcs-spinner mode="border"></wcs-spinner>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-login-shortcuts">
|
||||
<a className="admin-login-link" href="/">Retour à la connexion</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Mode 2 : Email envoyé */}
|
||||
{mode === 'sent' && (
|
||||
<div className="admin-login-form reset-password-confirmation">
|
||||
<div className="reset-password-info-card">
|
||||
<h3>Vérifiez votre messagerie</h3>
|
||||
<p>
|
||||
Un email avec un lien de réinitialisation sécurisé vient d’être envoyé à <strong>{targetEmail}</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Ce lien est valable pour une durée limitée. Pensez à vérifier vos courriers indésirables (spams) si vous ne le recevez pas d'ici quelques instants.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="reset-password-actions">
|
||||
<wcs-button
|
||||
className="wcs-primary"
|
||||
type="button"
|
||||
onClick={() => window.location.assign('/')}
|
||||
>
|
||||
Retour à la connexion
|
||||
</wcs-button>
|
||||
<wcs-button
|
||||
className="wcs-ghost"
|
||||
type="button"
|
||||
onClick={() => setMode('request')}
|
||||
>
|
||||
Renvoyer un email
|
||||
</wcs-button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode 3 : Saisie du nouveau mot de passe */}
|
||||
{mode === 'reset' && (
|
||||
<form onSubmit={handleUpdatePassword}>
|
||||
<div className="admin-login-form">
|
||||
<label htmlFor="new-password">Nouveau mot de passe (min. 8 caractères)</label>
|
||||
<wcs-input
|
||||
id="new-password"
|
||||
ref={passwordRef}
|
||||
type="password"
|
||||
placeholder="Nouveau mot de passe"
|
||||
name="password"
|
||||
></wcs-input>
|
||||
|
||||
<label htmlFor="confirm-password">Confirmer le nouveau mot de passe</label>
|
||||
<wcs-input
|
||||
id="confirm-password"
|
||||
ref={confirmPasswordRef}
|
||||
type="password"
|
||||
placeholder="Confirmez le mot de passe"
|
||||
name="confirmPassword"
|
||||
></wcs-input>
|
||||
|
||||
{error && (
|
||||
<wcs-alert intent="error" closable timeout={4000}>
|
||||
<span slot="title">Erreur</span>
|
||||
<span slot="subtitle">{error}</span>
|
||||
</wcs-alert>
|
||||
)}
|
||||
|
||||
<div className="reset-password-actions">
|
||||
<div className="reset-password-buttons">
|
||||
<wcs-button
|
||||
className="wcs-primary"
|
||||
type="submit"
|
||||
onClick={handleUpdatePassword}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Modification...' : 'Enregistrer le mot de passe'}
|
||||
</wcs-button>
|
||||
</div>
|
||||
<div className="admin-login-spinner">
|
||||
{loading && <wcs-spinner mode="border"></wcs-spinner>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Mode 4 : Succès */}
|
||||
{mode === 'success' && (
|
||||
<div className="admin-login-form reset-password-confirmation">
|
||||
<wcs-alert intent="success">
|
||||
<span slot="title">Mot de passe réinitialisé !</span>
|
||||
<span slot="subtitle">
|
||||
Votre mot de passe a été modifié avec succès. Vous pouvez désormais vous connecter.
|
||||
</span>
|
||||
</wcs-alert>
|
||||
|
||||
<p className="reset-password-countdown">
|
||||
Redirection automatique vers la page de connexion dans <strong>{countdown}s</strong>...
|
||||
</p>
|
||||
|
||||
<div className="reset-password-actions">
|
||||
<wcs-button
|
||||
className="wcs-primary"
|
||||
type="button"
|
||||
onClick={() => window.location.assign('/')}
|
||||
>
|
||||
Se connecter immédiatement
|
||||
</wcs-button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -252,3 +252,48 @@ export function canPerform(user, moduleKey, action) {
|
||||
export function hasAccessTo(user, section) {
|
||||
return canPerform(user, section, 'read');
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit un profil utilisateur complet et ses permissions à partir d'un objet Supabase Auth (GoTrue).
|
||||
* Utilise en priorité les métadonnées de l'utilisateur (user_metadata / app_metadata).
|
||||
* Cela permet de gérer entièrement les profils et les permissions sans nécessiter de table personnalisée.
|
||||
*/
|
||||
export function buildUserProfileFromAuth(supabaseUser, dbProfile = null) {
|
||||
if (!supabaseUser) return null;
|
||||
|
||||
const metadata = {
|
||||
...(supabaseUser.app_metadata || {}),
|
||||
...(supabaseUser.user_metadata || {})
|
||||
};
|
||||
|
||||
const role = dbProfile?.role || metadata.role || 'admin';
|
||||
const nom = dbProfile?.nom || metadata.nom || metadata.last_name || '';
|
||||
const prenom = dbProfile?.prenom || metadata.prenom || metadata.first_name || '';
|
||||
|
||||
// Déterminer les permissions
|
||||
let permissions = dbProfile?.permissions || metadata.permissions;
|
||||
if (!permissions || typeof permissions !== 'object' || Object.keys(permissions).length === 0) {
|
||||
// Gabarit par défaut selon le rôle
|
||||
permissions = ROLE_PERMISSION_TEMPLATES[role] || ROLE_PERMISSION_TEMPLATES.admin;
|
||||
}
|
||||
|
||||
const email = (supabaseUser.email || dbProfile?.email || '').trim().toLowerCase();
|
||||
const emailPrefix = email ? email.split('@')[0] : 'Utilisateur';
|
||||
const computedName = [nom, prenom].filter(Boolean).join(' ').trim();
|
||||
const name = computedName || metadata.name || metadata.full_name || emailPrefix;
|
||||
|
||||
return {
|
||||
id: supabaseUser.id || dbProfile?.id,
|
||||
email: email,
|
||||
nom: nom,
|
||||
prenom: prenom,
|
||||
name: name,
|
||||
role: role,
|
||||
permissions: permissions,
|
||||
region_admin: dbProfile ? dbProfile.region_admin === true : metadata.region_admin === true,
|
||||
region_id: dbProfile?.region_id || metadata.region_id || null,
|
||||
region_nom: dbProfile?.region_nom || metadata.region_nom || null,
|
||||
photo_url: dbProfile?.photo_url || metadata.photo_url || null,
|
||||
actif: dbProfile ? dbProfile.actif !== false : metadata.actif !== false
|
||||
};
|
||||
}
|
||||
@@ -48,3 +48,63 @@ describe('RBAC permissions-only', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserProfileFromAuth', () => {
|
||||
const { buildUserProfileFromAuth } = require('./userUtils');
|
||||
|
||||
test('construit le profil depuis user_metadata sans table DB', () => {
|
||||
const authUser = {
|
||||
id: 'uuid-1234',
|
||||
email: 'agent@sncf.fr',
|
||||
user_metadata: {
|
||||
nom: 'Martin',
|
||||
prenom: 'Paul',
|
||||
role: 'gestionnaire',
|
||||
permissions: {
|
||||
gares: { read: true, create: true, update: true, delete: false }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const profile = buildUserProfileFromAuth(authUser);
|
||||
|
||||
expect(profile.id).toBe('uuid-1234');
|
||||
expect(profile.email).toBe('agent@sncf.fr');
|
||||
expect(profile.nom).toBe('Martin');
|
||||
expect(profile.prenom).toBe('Paul');
|
||||
expect(profile.name).toBe('Martin Paul');
|
||||
expect(profile.role).toBe('gestionnaire');
|
||||
expect(profile.permissions.gares.read).toBe(true);
|
||||
expect(profile.actif).toBe(true);
|
||||
});
|
||||
|
||||
test('applique un rôle admin et ses permissions complètes par défaut si métadonnées absentes', () => {
|
||||
const authUser = {
|
||||
id: 'uuid-admin',
|
||||
email: 'admin@ferrovia.fr',
|
||||
user_metadata: {}
|
||||
};
|
||||
|
||||
const profile = buildUserProfileFromAuth(authUser);
|
||||
|
||||
expect(profile.id).toBe('uuid-admin');
|
||||
expect(profile.email).toBe('admin@ferrovia.fr');
|
||||
expect(profile.role).toBe('admin');
|
||||
expect(profile.permissions.dashboard.read).toBe(true);
|
||||
expect(profile.permissions.gares.create).toBe(true);
|
||||
});
|
||||
|
||||
test('gère le cas où l\'utilisateur est désactivé', () => {
|
||||
const authUser = {
|
||||
id: 'uuid-inactif',
|
||||
email: 'inactif@ferrovia.fr',
|
||||
user_metadata: {
|
||||
actif: false
|
||||
}
|
||||
};
|
||||
|
||||
const profile = buildUserProfileFromAuth(authUser);
|
||||
expect(profile.actif).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Guide de Configuration : Authentification & Envoi d'Emails Supabase Self-Hosted
|
||||
|
||||
Ce document détaille la configuration complète pour :
|
||||
1. **Gérer les profils et permissions sans table dédiée** via Supabase Auth (`user_metadata`).
|
||||
2. **Configurer l'envoi d'emails SMTP** sur une instance Supabase Self-Hosted.
|
||||
3. **Appliquer le template HTML de réinitialisation de mot de passe**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stockage des Profils et Permissions sans Table Dédiée
|
||||
|
||||
Supabase Auth intègre nativement un champ JSONB nommé `raw_user_meta_data` dans la table système `auth.users`.
|
||||
L'application Ferrovia Panel utilise désormais ce champ pour stocker les profils utilisateurs et leurs permissions RBAC sans nécessiter de table `public.utilisateurs`.
|
||||
|
||||
### Structure des Métadonnées Utilisateur (`user_metadata`)
|
||||
```json
|
||||
{
|
||||
"nom": "Dupont",
|
||||
"prenom": "Jean",
|
||||
"name": "Jean Dupont",
|
||||
"role": "admin",
|
||||
"permissions": {
|
||||
"dashboard": { "read": true, "create": true, "update": true, "delete": true },
|
||||
"gares": { "read": true, "create": true, "update": true, "delete": true },
|
||||
"horaires": { "read": true, "create": true, "update": true, "delete": true }
|
||||
},
|
||||
"region_admin": false,
|
||||
"region_id": null,
|
||||
"region_nom": null,
|
||||
"actif": true
|
||||
}
|
||||
```
|
||||
|
||||
### Consultation et Modification directe depuis Supabase (SQL)
|
||||
Si vous souhaitez attribuer le rôle administrateur ou des permissions personnalisées à un utilisateur directement en base de données :
|
||||
|
||||
```sql
|
||||
-- Mettre à jour les métadonnées d'un utilisateur existant :
|
||||
UPDATE auth.users
|
||||
SET raw_user_meta_data = jsonb_build_object(
|
||||
'nom', 'Administrateur',
|
||||
'prenom', 'Principal',
|
||||
'name', 'Principal Administrateur',
|
||||
'role', 'admin',
|
||||
'actif', true,
|
||||
'permissions', '{
|
||||
"dashboard": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"gares": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"materiel_roulant": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"horaires": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"fiches_horaires": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"lignes": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"services": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"quais": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"informations": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"perturbations": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"travaux": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"menu": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"pages": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"tarifs": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"messagerie": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"parametres": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"utilisateurs_global": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"annonces_sonores": {"read": true, "create": true, "update": true, "delete": true},
|
||||
"systeme_maj": {"read": true, "create": true, "update": true, "delete": true}
|
||||
}'::jsonb
|
||||
)
|
||||
WHERE email = 'votre-email@domaine.com';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration SMTP sur Supabase Self-Hosted (Docker)
|
||||
|
||||
Sur votre serveur hébergeant Supabase (ex: avec Docker Compose), éditez le fichier `.env` du dossier Supabase.
|
||||
|
||||
### Variables d'environnement pour l'envoi de mail (GoTrue / Auth)
|
||||
|
||||
```env
|
||||
## Configuration Générale du Site
|
||||
SITE_URL=https://votre-domaine.fr
|
||||
ADDITIONAL_REDIRECT_URLS=https://votre-domaine.fr/reinitialiser-mot-de-passe,http://localhost:3000/reinitialiser-mot-de-passe
|
||||
|
||||
## Activation du Serveur SMTP
|
||||
GOTRUE_SMTP_HOST=smtp.votre-fournisseur.com
|
||||
GOTRUE_SMTP_PORT=587
|
||||
GOTRUE_SMTP_USER=votre-utilisateur-smtp
|
||||
GOTRUE_SMTP_PASS=votre-mot-de-passe-smtp
|
||||
GOTRUE_SMTP_ADMIN_EMAIL=noreply@votre-domaine.fr
|
||||
GOTRUE_SMTP_SENDER_NAME="Ferrovia Panel"
|
||||
|
||||
## Paramètres des liens d'authentification
|
||||
GOTRUE_MAILER_AUTOCONFIRM=false
|
||||
GOTRUE_MAILER_URLPATHS_RECOVERY=/reinitialiser-mot-de-passe
|
||||
```
|
||||
|
||||
> **Note Fournisseurs SMTP recommandés :** Brevo (ex-Sendinblue), Mailjet, Resend, Amazon SES ou un serveur Postfix dédié.
|
||||
|
||||
---
|
||||
|
||||
## 3. Installation du Template Email de Réinitialisation
|
||||
|
||||
Le template email responsive Ferrovia est situé dans :
|
||||
`supabase/templates/recovery.html`
|
||||
|
||||
### Option A : Via Supabase Studio (Interface Web)
|
||||
1. Rendez-vous sur votre interface Supabase Studio (`https://votre-supabase/project/default/auth/templates`).
|
||||
2. Allez dans **Authentication** > **Email Templates**.
|
||||
3. Sélectionnez l'onglet **Reset Password**.
|
||||
4. Dans le champ **Subject**, indiquez :
|
||||
`Réinitialisation de votre mot de passe - Ferrovia Panel`
|
||||
5. Copiez-collez l'intégralité du code HTML du fichier `supabase/templates/recovery.html` dans le champ **Body**.
|
||||
6. Cliquez sur **Save changes**.
|
||||
|
||||
### Option B : Montage Docker (GoTrue)
|
||||
Si votre déploiement Docker utilise des fichiers de templates montés :
|
||||
1. Dans `docker-compose.yml`, montez le template dans le conteneur `auth` :
|
||||
```yaml
|
||||
auth:
|
||||
image: supabase/gotrue:...
|
||||
volumes:
|
||||
- ./templates/recovery.html:/etc/gotrue/templates/recovery.html:ro
|
||||
environment:
|
||||
GOTRUE_MAILER_TEMPLATES_RECOVERY: /etc/gotrue/templates/recovery.html
|
||||
```
|
||||
2. Redémarrez le conteneur auth :
|
||||
```bash
|
||||
docker compose restart auth
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Parcours Utilisateur dans l'Application
|
||||
|
||||
1. **Page de Connexion (`/`)** :
|
||||
- L'utilisateur clique sur **"Mot de passe oublié ?"**.
|
||||
- Si un email était déjà tapé, il est automatiquement pré-rempli sur la page de réinitialisation.
|
||||
|
||||
2. **Page de Réinitialisation (`/reinitialiser-mot-de-passe`) - Étape 1** :
|
||||
- L'utilisateur saisit son adresse email professionnelle.
|
||||
- L'application appelle `supabase.auth.resetPasswordForEmail(email, { redirectTo })`.
|
||||
- Supabase génère un token sécurisé et expédie l'email avec le template personnalisé.
|
||||
|
||||
3. **Email Reçu** :
|
||||
- L'utilisateur clique sur **"Définir mon nouveau mot de passe"**.
|
||||
- Le lien redirige vers `https://votre-domaine.fr/reinitialiser-mot-de-passe#access_token=...&type=recovery`.
|
||||
|
||||
4. **Page de Réinitialisation - Étape 2** :
|
||||
- L'application détecte le mode récupération (`type=recovery`).
|
||||
- L'utilisateur saisit son nouveau mot de passe (min. 8 caractères) et confirme.
|
||||
- L'application appelle `supabase.auth.updateUser({ password })`.
|
||||
- Message de confirmation et redirection automatique vers la connexion.
|
||||
@@ -248,6 +248,10 @@ otp_expiry = 3600
|
||||
# subject = "You have been invited"
|
||||
# content_path = "./supabase/templates/invite.html"
|
||||
|
||||
[auth.email.template.recovery]
|
||||
subject = "Réinitialisation de votre mot de passe - Ferrovia Panel"
|
||||
content_path = "./supabase/templates/recovery.html"
|
||||
|
||||
# Uncomment to customize notification email template
|
||||
# [auth.email.notification.password_changed]
|
||||
# enabled = true
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
-- ============================================================================
|
||||
-- MIGRATION : OPTIMISATION DE LA TABLE UNIQUE "horaires"
|
||||
-- Date : 2026-10-01
|
||||
-- Objectif : Consolider 100% des données relatives aux horaires (sillons, dessertes,
|
||||
-- quais, arrêts intermédiaires, substitutions, calendrier, matériel,
|
||||
-- temps réel) dans une seule et unique table "horaires" ultra-performante.
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. Ajout de toutes les colonnes nécessaires à la table horaires
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Identifiants et informations train
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'nom_train') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "nom_train" varchar(255);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'code_mission') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "code_mission" varchar(50);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'sens') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "sens" varchar(20) DEFAULT 'aller';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'transporteur') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "transporteur" varchar(100) DEFAULT 'SNCF Voyageurs';
|
||||
END IF;
|
||||
|
||||
-- Dessertes et attribution des quais
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'gares_desservies') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "gares_desservies" jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'attributions_quais') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "attributions_quais" jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
END IF;
|
||||
|
||||
-- Calendrier étendu et dates de validité
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'date_debut') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "date_debut" timestamptz;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'date_fin') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "date_fin" timestamptz;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'calendrier_circulation') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "calendrier_circulation" jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'periode_validite') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "periode_validite" jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
END IF;
|
||||
|
||||
-- Matériel et composition
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'composition_train') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "composition_train" jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
-- Région
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'region') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "region" varchar(255);
|
||||
END IF;
|
||||
|
||||
-- Service annuel et type de service
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'service_type') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "service_type" varchar(50) DEFAULT 'TER';
|
||||
END IF;
|
||||
|
||||
-- Substitutions et autocars
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'est_substitution') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "est_substitution" boolean NOT NULL DEFAULT false;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'motif_substitution') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "motif_substitution" varchar(255);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'substitution_disponible') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "substitution_disponible" boolean NOT NULL DEFAULT false;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'horaire_id') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "horaire_id" uuid;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'substitutions_associees') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "substitutions_associees" jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
END IF;
|
||||
|
||||
-- Sillons & infrastructure
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'sillon_id') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "sillon_id" text;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'tranche') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "tranche" varchar(50);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'gestionnaire_infrastructure') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "gestionnaire_infrastructure" varchar(100) DEFAULT 'SNCF Réseau';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'vitesse_max_kmh') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "vitesse_max_kmh" integer;
|
||||
END IF;
|
||||
|
||||
-- Exploitation temps réel et retards
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'statut') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "statut" varchar(50) NOT NULL DEFAULT 'a_l_heure';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'retard_minutes') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "retard_minutes" integer NOT NULL DEFAULT 0;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'retard_motif') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "retard_motif" text;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'informations_voyageurs') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "informations_voyageurs" text;
|
||||
END IF;
|
||||
|
||||
-- Métadonnées et notes
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'metadata') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "metadata" jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'horaires' AND column_name = 'notes') THEN
|
||||
ALTER TABLE "public"."horaires" ADD COLUMN "notes" text;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 2. Migration des données relationnelles de horaires_gares_desservies vers horaires.gares_desservies (si la table existe)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'horaires_gares_desservies') THEN
|
||||
-- Mettre à jour horaires.gares_desservies avec les lignes de la table relationnelle si gares_desservies est vide
|
||||
UPDATE "public"."horaires" h
|
||||
SET gares_desservies = sub.json_dessertes,
|
||||
updated_at = NOW()
|
||||
FROM (
|
||||
SELECT
|
||||
horaire_id,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'gare_id', gare_id,
|
||||
'gare_nom', gare_nom,
|
||||
'heure_arrivee', heure_arrivee,
|
||||
'heure_depart', heure_depart,
|
||||
'ordre', ordre,
|
||||
'quai', quai
|
||||
) ORDER BY ordre ASC
|
||||
) AS json_dessertes
|
||||
FROM "public"."horaires_gares_desservies"
|
||||
GROUP BY horaire_id
|
||||
) sub
|
||||
WHERE h.id = sub.horaire_id
|
||||
AND (h.gares_desservies IS NULL OR h.gares_desservies = '[]'::jsonb);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 3. Migration des substitutions de horaires_substitutions vers horaires (si la table existe)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'horaires_substitutions') THEN
|
||||
INSERT INTO "public"."horaires" (
|
||||
"id",
|
||||
"numero_train",
|
||||
"type_train",
|
||||
"type_train_id",
|
||||
"gare_depart_id",
|
||||
"gare_depart_nom",
|
||||
"heure_depart",
|
||||
"gare_arrivee_id",
|
||||
"gare_arrivee_nom",
|
||||
"heure_arrivee",
|
||||
"date_debut",
|
||||
"date_fin",
|
||||
"circule_lundi",
|
||||
"circule_mardi",
|
||||
"circule_mercredi",
|
||||
"circule_jeudi",
|
||||
"circule_vendredi",
|
||||
"circule_samedi",
|
||||
"circule_dimanche",
|
||||
"circule_jours_feries",
|
||||
"jours_personnalises",
|
||||
"jours_non_circulation",
|
||||
"materiel_roulant_id",
|
||||
"ligne_id",
|
||||
"ligne_nom",
|
||||
"service_annuel_id",
|
||||
"service_annuel_nom",
|
||||
"est_substitution",
|
||||
"motif_substitution",
|
||||
"gares_desservies",
|
||||
"composition_train",
|
||||
"substitution_disponible",
|
||||
"actif",
|
||||
"region_id",
|
||||
"region",
|
||||
"horaire_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at"
|
||||
)
|
||||
SELECT
|
||||
s."id",
|
||||
COALESCE(s."numero_train", 'SUBST-' || substring(s."id"::text, 1, 6)),
|
||||
s."type_train",
|
||||
s."type_train_id",
|
||||
s."gare_depart_id",
|
||||
s."gare_depart_nom",
|
||||
s."heure_depart",
|
||||
s."gare_arrivee_id",
|
||||
s."gare_arrivee_nom",
|
||||
s."heure_arrivee",
|
||||
s."date_debut",
|
||||
s."date_fin",
|
||||
COALESCE(s."circule_lundi", true),
|
||||
COALESCE(s."circule_mardi", true),
|
||||
COALESCE(s."circule_mercredi", true),
|
||||
COALESCE(s."circule_jeudi", true),
|
||||
COALESCE(s."circule_vendredi", true),
|
||||
COALESCE(s."circule_samedi", true),
|
||||
COALESCE(s."circule_dimanche", true),
|
||||
COALESCE(s."circule_jours_feries", false),
|
||||
COALESCE(s."jours_personnalises", '[]'::jsonb),
|
||||
COALESCE(s."jours_non_circulation", '[]'::jsonb),
|
||||
s."materiel_roulant_id",
|
||||
s."ligne_id",
|
||||
s."ligne_nom",
|
||||
s."service_annuel_id",
|
||||
s."service_annuel_nom",
|
||||
true AS est_substitution,
|
||||
s."motif_substitution",
|
||||
COALESCE(s."gares_desservies", '[]'::jsonb),
|
||||
COALESCE(s."composition_train", '[]'::jsonb),
|
||||
false,
|
||||
COALESCE(s."actif", true),
|
||||
s."region_id",
|
||||
s."region",
|
||||
s."horaire_id",
|
||||
COALESCE(s."created_at", NOW()),
|
||||
COALESCE(s."updated_at", NOW()),
|
||||
s."deleted_at"
|
||||
FROM "public"."horaires_substitutions" s
|
||||
ON CONFLICT ("id") DO UPDATE
|
||||
SET est_substitution = true,
|
||||
motif_substitution = EXCLUDED.motif_substitution,
|
||||
date_debut = EXCLUDED.date_debut,
|
||||
date_fin = EXCLUDED.date_fin,
|
||||
updated_at = NOW();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 4. Initialisation du dictionnaire attributions_quais depuis gares_desservies
|
||||
UPDATE "public"."horaires"
|
||||
SET attributions_quais = (
|
||||
SELECT COALESCE(
|
||||
jsonb_object_agg(
|
||||
COALESCE(elem->>'gare_id', elem->>'gare_nom'),
|
||||
jsonb_build_object(
|
||||
'quai', elem->>'quai',
|
||||
'quai_reel', elem->>'quai_reel',
|
||||
'ordre', elem->'ordre'
|
||||
)
|
||||
),
|
||||
'{}'::jsonb
|
||||
)
|
||||
FROM jsonb_array_elements(gares_desservies) elem
|
||||
WHERE elem->>'quai' IS NOT NULL OR elem->>'quai_reel' IS NOT NULL
|
||||
)
|
||||
WHERE attributions_quais = '{}'::jsonb
|
||||
AND gares_desservies IS NOT NULL
|
||||
AND jsonb_array_length(gares_desservies) > 0;
|
||||
|
||||
-- 5. Création des index haute performance (B-Tree et GIN)
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_numero_train" ON "public"."horaires" ("numero_train");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_ligne_id" ON "public"."horaires" ("ligne_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_region_id" ON "public"."horaires" ("region_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_departs" ON "public"."horaires" ("gare_depart_id", "heure_depart");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_arrivees" ON "public"."horaires" ("gare_arrivee_id", "heure_arrivee");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_actif" ON "public"."horaires" ("actif") WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_substitution" ON "public"."horaires" ("est_substitution");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_dates" ON "public"."horaires" ("date_debut", "date_fin");
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_statut" ON "public"."horaires" ("statut");
|
||||
|
||||
-- Index GIN JSONB pour recherche et filtrage ultra-rapide sans jointure
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_gares_desservies_gin"
|
||||
ON "public"."horaires" USING gin ("gares_desservies" jsonb_path_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_attributions_quais_gin"
|
||||
ON "public"."horaires" USING gin ("attributions_quais");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_calendrier_gin"
|
||||
ON "public"."horaires" USING gin ("calendrier_circulation" jsonb_path_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_horaires_metadata_gin"
|
||||
ON "public"."horaires" USING gin ("metadata");
|
||||
|
||||
-- 6. Trigger de mise à jour automatique de updated_at
|
||||
CREATE OR REPLACE TRIGGER "tr_horaires_set_updated_at"
|
||||
BEFORE UPDATE ON "public"."horaires"
|
||||
FOR EACH ROW EXECUTE FUNCTION "public"."set_updated_at"();
|
||||
|
||||
-- 7. Politiques de sécurité RLS
|
||||
ALTER TABLE "public"."horaires" ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Public read access" ON "public"."horaires";
|
||||
CREATE POLICY "Public read access" ON "public"."horaires" FOR SELECT USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Public insert access" ON "public"."horaires";
|
||||
CREATE POLICY "Public insert access" ON "public"."horaires" FOR INSERT WITH CHECK (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Public update access" ON "public"."horaires";
|
||||
CREATE POLICY "Public update access" ON "public"."horaires" FOR UPDATE USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "Public delete access" ON "public"."horaires";
|
||||
CREATE POLICY "Public delete access" ON "public"."horaires" FOR DELETE USING (true);
|
||||
|
||||
-- Commentaires d'optimisation
|
||||
COMMENT ON TABLE "public"."horaires" IS 'Table UNIQUE et centrale pour stocker l''intégralité des données horaires, dessertes, quais, substitutions, calendrier et temps réel.';
|
||||
COMMENT ON COLUMN "public"."horaires"."gares_desservies" IS 'Liste JSONB complète et ordonnée de tous les arrêts, gares, horaires et quais.';
|
||||
COMMENT ON COLUMN "public"."horaires"."attributions_quais" IS 'Dictionnaire JSONB rapide O(1) { [gare_id]: { quai, quai_reel } } sans jointure.';
|
||||
COMMENT ON COLUMN "public"."horaires"."est_substitution" IS 'Vrai si ce sillon représente un autocar ou train de substitution.';
|
||||
@@ -0,0 +1,252 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Réinitialisation de votre mot de passe - Ferrovia Panel</title>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<style>
|
||||
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
background-color: #f1f5f9;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #1e293b;
|
||||
}
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
background-color: #f1f5f9;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
.main {
|
||||
background-color: #ffffff;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
border-spacing: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #062743 0%, #0e3b62 60%, #1a4e7a 100%);
|
||||
padding: 36px 40px;
|
||||
text-align: left;
|
||||
}
|
||||
.kicker {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: #93c5fd;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.brand-title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: #cbd5e1;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
.content {
|
||||
padding: 40px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.text {
|
||||
font-size: 15px;
|
||||
color: #334155;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.btn-container {
|
||||
text-align: center;
|
||||
padding: 24px 0 32px 0;
|
||||
}
|
||||
.button {
|
||||
background-color: #0070f3;
|
||||
background-image: linear-gradient(135deg, #0066cc 0%, #004c99 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 4px 12px rgba(0, 102, 204, 0.25);
|
||||
}
|
||||
.token-box {
|
||||
background-color: #f8fafc;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.token-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #64748b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.token-code {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4px;
|
||||
color: #0369a1;
|
||||
}
|
||||
.alert-box {
|
||||
background-color: #eff6ff;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 14px 16px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin: 24px 0;
|
||||
font-size: 13.5px;
|
||||
color: #1e40af;
|
||||
}
|
||||
.fallback-url {
|
||||
background-color: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
color: #64748b;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.footer a {
|
||||
color: #64748b;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 30px; padding-bottom: 20px;">
|
||||
<!-- CONTENEUR PRINCIPAL -->
|
||||
<table class="main" role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||
<!-- EN-TÊTE BRANDING -->
|
||||
<tr>
|
||||
<td class="header">
|
||||
<div class="kicker">Console d'exploitation</div>
|
||||
<h1 class="brand-title">FERROVIA PANEL</h1>
|
||||
<p class="brand-subtitle">Gestion régionale et système d'information ferroviaire</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- CONTENU DU MESSAGE -->
|
||||
<tr>
|
||||
<td class="content">
|
||||
<h2 class="greeting">Réinitialisation de votre mot de passe</h2>
|
||||
|
||||
<p class="text">
|
||||
Bonjour,
|
||||
</p>
|
||||
|
||||
<p class="text">
|
||||
Une demande de réinitialisation de mot de passe a été effectuée pour le compte administrateur associé à l'adresse <strong>{{ .Email }}</strong>.
|
||||
</p>
|
||||
|
||||
<p class="text">
|
||||
Cliquez sur le bouton ci-dessous pour choisir votre nouveau mot de passe sécurisé :
|
||||
</p>
|
||||
|
||||
<!-- BOUTON D'ACTION PRINCIPAL -->
|
||||
<div class="btn-container">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ .ConfirmationURL }}" style="height:48px;v-text-anchor:middle;width:260px;" arcsize="15%" stroke="f" fillcolor="#0066cc">
|
||||
<w:anchorlock/>
|
||||
<center style="color:#ffffff;font-family:sans-serif;font-size:15px;font-weight:bold;">
|
||||
Définir mon mot de passe →
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<a href="{{ .ConfirmationURL }}" class="button" target="_blank">
|
||||
Définir mon nouveau mot de passe →
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</div>
|
||||
|
||||
{{ if .Token }}
|
||||
<div class="token-box">
|
||||
<div class="token-label">Code de sécurité à usage unique</div>
|
||||
<div class="token-code">{{ .Token }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="alert-box">
|
||||
<strong>Important :</strong> Ce lien de sécurité est temporaire et expire automatiquement après <strong>1 heure</strong>.
|
||||
</div>
|
||||
|
||||
<p class="text" style="font-size: 13px; color: #64748b; margin-bottom: 6px;">
|
||||
Si le bouton ne fonctionne pas, copiez et collez l'URL suivante dans votre navigateur :
|
||||
</p>
|
||||
<div class="fallback-url">
|
||||
{{ .ConfirmationURL }}
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 30px 0 20px 0;" />
|
||||
|
||||
<p class="text" style="font-size: 13px; color: #94a3b8; margin-bottom: 0;">
|
||||
Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet email en toute sécurité. Votre mot de passe actuel restera inchangé et votre compte demeure protégé.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- PIED DE PAGE -->
|
||||
<table role="presentation" width="100%" style="max-width: 600px;" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td class="footer">
|
||||
<p style="margin: 8px 0;">
|
||||
<strong>Ferrovia Panel</strong> • Système d'Exploitation Ferroviaire
|
||||
</p>
|
||||
<p style="margin: 4px 0;">
|
||||
Cet email a été envoyé automatiquement par le serveur d'authentification Supabase. Merci de ne pas y répondre directement.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user