feat: Système de statuts enrichi avec descriptions markdown et refonte navigation

- Header: Ajout 3ème ligne de statut (Caisses & orga.) avec descriptions détaillées
- Tooltips: Affichage riche avec titre, voyant coloré et contenu markdown formaté
- Éditeur markdown: Nouveau composant RichTextEditor avec toolbar (gras, italique, liens, listes)
- Modal staff: StatusEditModal étendu avec onglets et éditeur de descriptions
- Migration: Ajout colonnes status_*_description dans maintenance_status
- API: Routes GET/PUT enrichies pour gérer les 9 champs de statut
- Navigation: Redirection /compte/securite → /securite (nouvelle page centralisée)
- Breadcrumb: Support contrats RG/CDDU multi + labels dynamiques salariés
- UX Documents: Bouton 'Nouvel onglet / Télécharger' au lieu de téléchargement forcé
- Contrats staff: Pagination paies (6/page) pour RG et CDDU multi-mois avec vue compacte
- PayslipCard: Bouton cliquable 'Ouvrir le PDF' pour accès direct aux bulletins
This commit is contained in:
odentas 2025-10-31 19:42:25 +01:00
parent 6485db4a75
commit 73e914a303
16 changed files with 1204 additions and 486 deletions

View file

@ -1,299 +1,23 @@
"use client";
import React, { useState, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { KeyRound, Lock, Loader2, Check, X, Eye, EyeOff } from "lucide-react";
import { MfaSetupComponent } from "@/components/auth/MfaSetupComponent";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
/**
* Critères de validation du mot de passe
* Page de redirection vers /securite
* Cette page redirige automatiquement pour maintenir les anciens liens (emails, etc.)
*/
const validatePassword = (password: string) => {
return {
minLength: password.length >= 12,
hasLowercase: /[a-z]/.test(password),
hasUppercase: /[A-Z]/.test(password),
hasNumber: /\d/.test(password),
hasSpecialChar: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password),
};
};
export default function CompteSecuriteRedirect() {
const router = useRouter();
const isPasswordValid = (validation: ReturnType<typeof validatePassword>) => {
return Object.values(validation).every(Boolean);
};
/**
* Page Compte / Sécurité
* - Création / mise à jour du mot de passe (facultatif)
* - 2FA désactivé temporairement
*/
export default function CompteSecuritePage() {
usePageTitle("Sécurité");
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
React.useEffect(() => {
let abort = false;
(async () => {
try {
const res = await fetch("/api/auth/password-status", {
credentials: "include",
cache: "no-store"
});
if (abort) return;
if (res.ok) {
const data = await res.json();
setHasPassword(data.hasPassword);
} else {
setHasPassword(null);
}
} catch (error) {
if (!abort) setHasPassword(null);
}
})();
return () => { abort = true; };
}, []);
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [pwBusy, setPwBusy] = useState(false);
const [showPw1, setShowPw1] = useState(false);
const [showPw2, setShowPw2] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
// Validation en temps réel du mot de passe
const passwordValidation = useMemo(() => validatePassword(pw1), [pw1]);
const isValidPassword = useMemo(() => isPasswordValid(passwordValidation), [passwordValidation]);
// Validation de la confirmation du mot de passe
const passwordMatch = useMemo(() => {
if (!pw2) return null; // Pas d'indication si vide
return pw1 === pw2;
}, [pw1, pw2]);
async function handleSetPassword() {
setPwMsg(null);
if (!isValidPassword) {
setPwMsg("Le mot de passe ne respecte pas tous les critères requis.");
return;
}
if (pw1 !== pw2) {
setPwMsg("Les mots de passe ne correspondent pas.");
return;
}
try {
setPwBusy(true);
const res = await fetch("/api/auth/password-update", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ newPassword: pw1 }),
});
if (res.ok) {
const data = await res.json();
setPw1("");
setPw2("");
setHasPassword(true);
setPwMsg("Mot de passe mis à jour ✨");
} else {
const errorData = await res.json();
setPwMsg(errorData.message || "Échec de la mise à jour");
}
} catch (e: any) {
setPwMsg(e?.message || "Erreur de connexion");
} finally {
setPwBusy(false);
}
}
useEffect(() => {
router.replace("/securite");
}, [router]);
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Sécurité du compte</h2>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Mot de passe (facultatif) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5"/>
Mot de passe (facultatif)
</CardTitle>
<CardDescription>
Ajoutez ou mettez à jour un mot de passe pour vous connecter sans envoi de code par e-mail.
</CardDescription>
<div className="mt-2">
{hasPassword === null ? (
<span className="inline-block text-xs text-slate-500">Vérification du statut</span>
) : hasPassword ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-emerald-100 text-emerald-700">
Mot de passe défini
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700">
Aucun mot de passe défini
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2">
<Label htmlFor="pw1">Nouveau mot de passe</Label>
<div className="relative">
<Input
id="pw1"
type={showPw1 ? "text" : "password"}
value={pw1}
onChange={(e)=>setPw1(e.target.value)}
placeholder="Saisissez votre nouveau mot de passe"
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPw1(!showPw1)}
>
{showPw1 ? <EyeOff className="h-4 w-4 text-gray-500" /> : <Eye className="h-4 w-4 text-gray-500" />}
</Button>
</div>
{/* Critères de validation en temps réel */}
{pw1.length > 0 && (
<div className="mt-2 p-3 bg-slate-50 rounded-lg border">
<div className="text-sm font-medium text-slate-700 mb-2">
Critères requis :
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm">
{passwordValidation.minLength ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.minLength ? "text-green-700" : "text-red-700"}>
Au moins 12 caractères
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasLowercase ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasLowercase ? "text-green-700" : "text-red-700"}>
Au moins une minuscule (a-z)
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasUppercase ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasUppercase ? "text-green-700" : "text-red-700"}>
Au moins une majuscule (A-Z)
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasNumber ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasNumber ? "text-green-700" : "text-red-700"}>
Au moins un chiffre (0-9)
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasSpecialChar ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasSpecialChar ? "text-green-700" : "text-red-700"}>
Au moins un caractère spécial (!@#$%^&*)
</span>
</div>
</div>
</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="pw2">Confirmer le mot de passe</Label>
<div className="relative">
<Input
id="pw2"
type={showPw2 ? "text" : "password"}
value={pw2}
onChange={(e)=>setPw2(e.target.value)}
placeholder="Confirmez votre mot de passe"
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPw2(!showPw2)}
>
{showPw2 ? <EyeOff className="h-4 w-4 text-gray-500" /> : <Eye className="h-4 w-4 text-gray-500" />}
</Button>
</div>
{/* Indication de correspondance du mot de passe */}
{pw2.length > 0 && (
<div className="flex items-center gap-2 text-sm">
{passwordMatch === true ? (
<>
<Check className="h-4 w-4 text-green-600" />
<span className="text-green-700">
Les mots de passe correspondent
</span>
</>
) : (
<>
<X className="h-4 w-4 text-red-600" />
<span className="text-red-700">
Les mots de passe ne correspondent pas
</span>
</>
)}
</div>
)}
</div>
{pwMsg && (
<div className={`text-sm ${pwMsg.includes("✨") ? 'text-emerald-600' : 'text-rose-600'}`}>
{pwMsg}
</div>
)}
<div className="flex justify-end">
<Button
onClick={handleSetPassword}
disabled={pwBusy || !isValidPassword || pw1 !== pw2 || !pw1 || !pw2}
>
{pwBusy && <Loader2 className="h-4 w-4 mr-2 animate-spin"/>}
Enregistrer
</Button>
</div>
</CardContent>
</Card>
{/* 2FA / MFA TOTP */}
<MfaSetupComponent />
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-slate-600">Redirection vers la page Sécurité...</p>
</div>
</div>
);

300
app/(app)/securite/page.tsx Normal file
View file

@ -0,0 +1,300 @@
"use client";
import React, { useState, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { KeyRound, Lock, Loader2, Check, X, Eye, EyeOff } from "lucide-react";
import { MfaSetupComponent } from "@/components/auth/MfaSetupComponent";
import { usePageTitle } from "@/hooks/usePageTitle";
/**
* Critères de validation du mot de passe
*/
const validatePassword = (password: string) => {
return {
minLength: password.length >= 12,
hasLowercase: /[a-z]/.test(password),
hasUppercase: /[A-Z]/.test(password),
hasNumber: /\d/.test(password),
hasSpecialChar: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password),
};
};
const isPasswordValid = (validation: ReturnType<typeof validatePassword>) => {
return Object.values(validation).every(Boolean);
};
/**
* Page Sécurité
* - Création / mise à jour du mot de passe (facultatif)
* - Configuration 2FA
*/
export default function SecuritePage() {
usePageTitle("Sécurité");
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
React.useEffect(() => {
let abort = false;
(async () => {
try {
const res = await fetch("/api/auth/password-status", {
credentials: "include",
cache: "no-store"
});
if (abort) return;
if (res.ok) {
const data = await res.json();
setHasPassword(data.hasPassword);
} else {
setHasPassword(null);
}
} catch (error) {
if (!abort) setHasPassword(null);
}
})();
return () => { abort = true; };
}, []);
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [pwBusy, setPwBusy] = useState(false);
const [showPw1, setShowPw1] = useState(false);
const [showPw2, setShowPw2] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
// Validation en temps réel du mot de passe
const passwordValidation = useMemo(() => validatePassword(pw1), [pw1]);
const isValidPassword = useMemo(() => isPasswordValid(passwordValidation), [passwordValidation]);
// Validation de la confirmation du mot de passe
const passwordMatch = useMemo(() => {
if (!pw2) return null; // Pas d'indication si vide
return pw1 === pw2;
}, [pw1, pw2]);
async function handleSetPassword() {
setPwMsg(null);
if (!isValidPassword) {
setPwMsg("Le mot de passe ne respecte pas tous les critères requis.");
return;
}
if (pw1 !== pw2) {
setPwMsg("Les mots de passe ne correspondent pas.");
return;
}
try {
setPwBusy(true);
const res = await fetch("/api/auth/password-update", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ newPassword: pw1 }),
});
if (res.ok) {
const data = await res.json();
setPw1("");
setPw2("");
setHasPassword(true);
setPwMsg("Mot de passe mis à jour ✨");
} else {
const errorData = await res.json();
setPwMsg(errorData.message || "Échec de la mise à jour");
}
} catch (e: any) {
setPwMsg(e?.message || "Erreur de connexion");
} finally {
setPwBusy(false);
}
}
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Sécurité du compte</h2>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Mot de passe (facultatif) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5"/>
Mot de passe (facultatif)
</CardTitle>
<CardDescription>
Ajoutez ou mettez à jour un mot de passe pour vous connecter sans envoi de code par e-mail.
</CardDescription>
<div className="mt-2">
{hasPassword === null ? (
<span className="inline-block text-xs text-slate-500">Vérification du statut</span>
) : hasPassword ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-emerald-100 text-emerald-700">
Mot de passe défini
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700">
Aucun mot de passe défini
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2">
<Label htmlFor="pw1">Nouveau mot de passe</Label>
<div className="relative">
<Input
id="pw1"
type={showPw1 ? "text" : "password"}
value={pw1}
onChange={(e)=>setPw1(e.target.value)}
placeholder="Saisissez votre nouveau mot de passe"
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPw1(!showPw1)}
>
{showPw1 ? <EyeOff className="h-4 w-4 text-gray-500" /> : <Eye className="h-4 w-4 text-gray-500" />}
</Button>
</div>
{/* Critères de validation en temps réel */}
{pw1.length > 0 && (
<div className="mt-2 p-3 bg-slate-50 rounded-lg border">
<div className="text-sm font-medium text-slate-700 mb-2">
Critères requis :
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm">
{passwordValidation.minLength ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.minLength ? "text-green-700" : "text-red-700"}>
Au moins 12 caractères
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasLowercase ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasLowercase ? "text-green-700" : "text-red-700"}>
Au moins une minuscule (a-z)
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasUppercase ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasUppercase ? "text-green-700" : "text-red-700"}>
Au moins une majuscule (A-Z)
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasNumber ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasNumber ? "text-green-700" : "text-red-700"}>
Au moins un chiffre (0-9)
</span>
</div>
<div className="flex items-center gap-2 text-sm">
{passwordValidation.hasSpecialChar ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
<span className={passwordValidation.hasSpecialChar ? "text-green-700" : "text-red-700"}>
Au moins un caractère spécial (!@#$%^&*)
</span>
</div>
</div>
</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="pw2">Confirmer le mot de passe</Label>
<div className="relative">
<Input
id="pw2"
type={showPw2 ? "text" : "password"}
value={pw2}
onChange={(e)=>setPw2(e.target.value)}
placeholder="Confirmez votre mot de passe"
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPw2(!showPw2)}
>
{showPw2 ? <EyeOff className="h-4 w-4 text-gray-500" /> : <Eye className="h-4 w-4 text-gray-500" />}
</Button>
</div>
{/* Indication de correspondance du mot de passe */}
{pw2.length > 0 && (
<div className="flex items-center gap-2 text-sm">
{passwordMatch === true ? (
<>
<Check className="h-4 w-4 text-green-600" />
<span className="text-green-700">
Les mots de passe correspondent
</span>
</>
) : (
<>
<X className="h-4 w-4 text-red-600" />
<span className="text-red-700">
Les mots de passe ne correspondent pas
</span>
</>
)}
</div>
)}
</div>
{pwMsg && (
<div className={`text-sm ${pwMsg.includes("✨") ? 'text-emerald-600' : 'text-rose-600'}`}>
{pwMsg}
</div>
)}
<div className="flex justify-end">
<Button
onClick={handleSetPassword}
disabled={pwBusy || !isValidPassword || pw1 !== pw2 || !pw1 || !pw2}
>
{pwBusy && <Loader2 className="h-4 w-4 mr-2 animate-spin"/>}
Enregistrer
</Button>
</div>
</CardContent>
</Card>
{/* 2FA / MFA TOTP */}
<MfaSetupComponent />
</div>
</div>
);
}

View file

@ -13,7 +13,7 @@ export async function GET() {
const { data, error } = await sb
.from("maintenance_status")
.select("status_top_text, status_bottom_text, status_top_color, status_bottom_color")
.select("status_top_text, status_middle_text, status_bottom_text, status_top_color, status_middle_color, status_bottom_color, status_top_description, status_middle_description, status_bottom_description")
.single();
if (error) {
@ -53,10 +53,15 @@ export async function PUT(request: Request) {
// Récupérer les données de la requête
const body = await request.json();
const {
status_top_text,
status_top_text,
status_middle_text,
status_bottom_text,
status_top_color,
status_bottom_color
status_top_color,
status_middle_color,
status_bottom_color,
status_top_description,
status_middle_description,
status_bottom_description
} = body;
// Validation des couleurs autorisées
@ -67,6 +72,12 @@ export async function PUT(request: Request) {
error: `Invalid top color. Allowed colors: ${allowedColors.join(', ')}`
}, { status: 400 });
}
if (status_middle_color && !allowedColors.includes(status_middle_color)) {
return NextResponse.json({
error: `Invalid middle color. Allowed colors: ${allowedColors.join(', ')}`
}, { status: 400 });
}
if (status_bottom_color && !allowedColors.includes(status_bottom_color)) {
return NextResponse.json({
@ -80,12 +91,37 @@ export async function PUT(request: Request) {
error: "Top text must be a string with max 200 characters"
}, { status: 400 });
}
if (status_middle_text && (typeof status_middle_text !== 'string' || status_middle_text.length > 200)) {
return NextResponse.json({
error: "Middle text must be a string with max 200 characters"
}, { status: 400 });
}
if (status_bottom_text && (typeof status_bottom_text !== 'string' || status_bottom_text.length > 200)) {
return NextResponse.json({
error: "Bottom text must be a string with max 200 characters"
}, { status: 400 });
}
// Validation des descriptions (max 1000 caractères)
if (status_top_description && (typeof status_top_description !== 'string' || status_top_description.length > 1000)) {
return NextResponse.json({
error: "Top description must be a string with max 1000 characters"
}, { status: 400 });
}
if (status_middle_description && (typeof status_middle_description !== 'string' || status_middle_description.length > 1000)) {
return NextResponse.json({
error: "Middle description must be a string with max 1000 characters"
}, { status: 400 });
}
if (status_bottom_description && (typeof status_bottom_description !== 'string' || status_bottom_description.length > 1000)) {
return NextResponse.json({
error: "Bottom description must be a string with max 1000 characters"
}, { status: 400 });
}
// Construire l'objet de mise à jour (seulement les champs fournis)
const updateData: any = {
@ -93,16 +129,21 @@ export async function PUT(request: Request) {
};
if (status_top_text !== undefined) updateData.status_top_text = status_top_text;
if (status_middle_text !== undefined) updateData.status_middle_text = status_middle_text;
if (status_bottom_text !== undefined) updateData.status_bottom_text = status_bottom_text;
if (status_top_color !== undefined) updateData.status_top_color = status_top_color;
if (status_middle_color !== undefined) updateData.status_middle_color = status_middle_color;
if (status_bottom_color !== undefined) updateData.status_bottom_color = status_bottom_color;
if (status_top_description !== undefined) updateData.status_top_description = status_top_description;
if (status_middle_description !== undefined) updateData.status_middle_description = status_middle_description;
if (status_bottom_description !== undefined) updateData.status_bottom_description = status_bottom_description;
// Mettre à jour les informations de statut
const { data, error } = await sb
.from("maintenance_status")
.update(updateData)
.eq("id", 1)
.select("status_top_text, status_bottom_text, status_top_color, status_bottom_color, updated_at, updated_by_staff_id")
.select("status_top_text, status_middle_text, status_bottom_text, status_top_color, status_middle_color, status_bottom_color, status_top_description, status_middle_description, status_bottom_description, updated_at, updated_by_staff_id")
.single();
if (error) {

View file

@ -4,7 +4,7 @@
:root{
--brand:#f5c542;
--header-h:64px;
--header-h:100px;
--sidebar-w:274px;
}
html,body{ height:100%; }

View file

@ -9,6 +9,8 @@ import { useMemo, useState, useEffect } from "react";
const ROUTE_LABELS: Record<string, string> = {
"/": "Tableau de bord",
"/contrats": "Contrats & Paies",
"/contrats-rg": "Contrats & Paies",
"/contrats-multi": "Contrats & Paies",
"/salaries": "Salariés",
"/salaries/nouveau": "Nouveau salarié",
"/signatures-electroniques": "Signatures électroniques",
@ -18,10 +20,12 @@ const ROUTE_LABELS: Record<string, string> = {
"/informations": "Vos informations",
"/vos-documents": "Vos documents",
"/vos-acces": "Gestion des accès",
"/vos-acces/nouveau": "Nouvel utilisateur",
"/securite": "Sécurité",
"/minima-ccn": "Minima CCN",
"/minima-ccn/ccneac": "CCNEAC",
"/minima-ccn/ccnpa": "CCNPA",
"/minima-ccn/ccnsvp": "CCNSVP",
"/minima-ccn/ccneac": "Entreprises Artistiques & Culturelles",
"/minima-ccn/ccnpa": "Production Audiovisuelle",
"/minima-ccn/ccnsvp": "Spectacle Vivant Privé",
"/simulateur": "Simulateur de paie",
"/support": "Support",
"/debug": "Debug",
@ -51,15 +55,30 @@ function useDynamicLabel(segment: string, currentPath: string): string | null {
// Pattern : /salaries/[matricule]
if (currentPath.match(/^\/salaries\/[^/]+$/)) {
const matricule = segment;
const res = await fetch(`/api/salaries?matricule=${matricule}`, {
const res = await fetch(`/api/salaries?search=${encodeURIComponent(matricule)}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
if (data.length > 0) {
const salarie = data[0];
setLabel(`${salarie.prenom} ${salarie.nom}`.trim() || matricule);
if (data.items && data.items.length > 0) {
// Trouver le salarié exact avec le bon matricule
const salarie = data.items.find((s: any) =>
s.matricule === matricule || s.code_salarie === matricule
);
if (salarie) {
// Format: NOM Prénom (nom de famille en majuscules)
// Extraire nom et prénom du champ nom complet
const parts = salarie.nom?.split(' ') || [];
if (parts.length >= 2) {
const nom = parts[0]?.toUpperCase();
const prenom = parts.slice(1).join(' ');
setLabel(`${nom} ${prenom}`.trim());
} else {
setLabel(salarie.nom || matricule);
}
}
}
}
}
@ -89,6 +108,45 @@ function useDynamicLabel(segment: string, currentPath: string): string | null {
setLabel(data.subject || `Ticket #${id.substring(0, 8)}`);
}
}
// Pattern : /contrats-multi/[id] (CDDU multi-mois)
else if (currentPath.match(/^\/contrats-multi\/[^/]+$/)) {
const id = segment;
const res = await fetch(`/api/contrats/${id}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
setLabel(`CDDU multi-mois ${data.numero || id.substring(0, 8)}`);
}
}
// Pattern : /contrats-rg/[id] (Contrat Régime Général)
else if (currentPath.match(/^\/contrats-rg\/[^/]+$/)) {
const id = segment;
const res = await fetch(`/api/contrats/${id}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
setLabel(`Contrat Régime Général ${data.numero || id.substring(0, 8)}`);
}
}
// Pattern : /contrats/[id] (CDDU mono-mois ou autre)
else if (currentPath.match(/^\/contrats\/[^/]+$/)) {
const id = segment;
const res = await fetch(`/api/contrats/${id}`, {
credentials: 'include',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
setLabel(`CDDU ${data.numero || id.substring(0, 8)}`);
}
}
} catch (error) {
// En cas d'erreur, ne rien faire (on garde le label par défaut)
console.debug('Breadcrumb: impossible de récupérer le label dynamique', error);
@ -99,7 +157,10 @@ function useDynamicLabel(segment: string, currentPath: string): string | null {
if (
currentPath.match(/^\/salaries\/[^/]+$/) ||
currentPath.match(/^\/staff\/facturation\/[^/]+$/) ||
currentPath.match(/^\/support\/[^/]+$/)
currentPath.match(/^\/support\/[^/]+$/) ||
currentPath.match(/^\/contrats-multi\/[^/]+$/) ||
currentPath.match(/^\/contrats-rg\/[^/]+$/) ||
currentPath.match(/^\/contrats\/[^/]+$/)
) {
fetchLabel();
}
@ -151,10 +212,18 @@ export default function Breadcrumb() {
if (
currentPath.match(/^\/salaries\/[^/]+$/) ||
currentPath.match(/^\/staff\/facturation\/[^/]+$/) ||
currentPath.match(/^\/support\/[^/]+$/)
currentPath.match(/^\/support\/[^/]+$/) ||
currentPath.match(/^\/contrats-multi\/[^/]+$/) ||
currentPath.match(/^\/contrats-rg\/[^/]+$/) ||
currentPath.match(/^\/contrats\/[^/]+$/)
) {
isDynamic = true;
label = "Chargement..."; // Temporaire, sera remplacé par le hook
// Label temporaire selon le type de page
if (currentPath.match(/^\/salaries\/[^/]+$/)) {
label = "Salarié";
} else {
label = "Chargement...";
}
}
// Si c'est un UUID ou un nombre (probablement un ID)
else if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
@ -231,7 +300,7 @@ function BreadcrumbItem({
<ChevronRight className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" aria-hidden />
)}
{crumb.isLast ? (
<span className="text-slate-700 font-medium truncate max-w-[200px] flex items-center gap-1.5">
<span className="text-slate-700 font-medium truncate max-w-[400px] flex items-center gap-1.5">
{displayLabel}
{crumb.isStaff && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-semibold bg-indigo-100 text-indigo-700 border border-indigo-200">
@ -243,7 +312,7 @@ function BreadcrumbItem({
) : (
<Link
href={crumb.href}
className="text-slate-500 hover:text-indigo-600 transition truncate max-w-[150px] flex items-center gap-1.5"
className="text-slate-500 hover:text-indigo-600 transition truncate max-w-[300px] flex items-center gap-1.5"
title={displayLabel}
>
{index === 0 ? (

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { X, Trash2, Download, Loader2 } from "lucide-react";
import { X, Trash2, ExternalLink, Loader2 } from "lucide-react";
import { toast } from "sonner";
interface DocumentPreviewModalProps {
@ -103,11 +103,12 @@ export function DocumentPreviewModal({
</p>
<a
href={document.downloadUrl}
download={document.name}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Download className="h-4 w-4" />
Télécharger le fichier
<ExternalLink className="h-4 w-4" />
Ouvrir dans un nouvel onglet / Télécharger
</a>
</div>
</div>
@ -118,11 +119,12 @@ export function DocumentPreviewModal({
<div className="flex items-center justify-between p-4 border-t bg-gray-50">
<a
href={document.downloadUrl}
download={document.name}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Download className="h-4 w-4" />
Télécharger
<ExternalLink className="h-4 w-4" />
Nouvel onglet / Télécharger
</a>
{!showDeleteConfirm ? (

View file

@ -2,7 +2,9 @@
import { Search, Menu } from "lucide-react";
import * as React from "react";
import { Command } from "cmdk";
import Link from "next/link";
import StatusEditModal from "./StatusEditModal";
import { Tooltip } from "@/components/ui/tooltip";
type ClientInfo = {
id: string;
@ -18,28 +20,30 @@ type ClientInfo = {
interface StatusData {
status_top_text: string;
status_middle_text: string;
status_bottom_text: string;
status_top_color: string;
status_middle_color: string;
status_bottom_color: string;
status_top_description?: string;
status_middle_description?: string;
status_bottom_description?: string;
}
export default function Header({ clientInfo, isStaff }: {
clientInfo?: ClientInfo;
isStaff?: boolean;
}) {
const paletteRef = React.useRef<HTMLButtonElement | null>(null);
const paletteRef = React.useRef<HTMLDivElement | null>(null);
const [paletteH, setPaletteH] = React.useState<number | null>(null);
const [expandSection, setExpandSection] = React.useState<null | 'top' | 'bottom'>(null);
const topMsgRef = React.useRef<HTMLSpanElement | null>(null);
const botMsgRef = React.useRef<HTMLSpanElement | null>(null);
const [topTrunc, setTopTrunc] = React.useState(false);
const [botTrunc, setBotTrunc] = React.useState(false);
// État pour le modal et les données de statut
const [statusData, setStatusData] = React.useState<StatusData>({
status_top_text: 'Aucun incident à signaler.',
status_bottom_text: 'Nous serons exceptionnellement fermés le 3 novembre 2025.',
status_top_text: '',
status_middle_text: '',
status_bottom_text: '',
status_top_color: 'emerald',
status_middle_color: 'blue',
status_bottom_color: 'sky'
});
const [isModalOpen, setIsModalOpen] = React.useState(false);
@ -120,25 +124,73 @@ export default function Header({ clientInfo, isStaff }: {
return textColorMap[color] || 'text-gray-700';
}, []);
const recomputeTrunc = React.useCallback(() => {
const isTrunc = (el: HTMLSpanElement | null) => !!el && el.scrollWidth > el.clientWidth + 1;
setTopTrunc(isTrunc(topMsgRef.current));
setBotTrunc(isTrunc(botMsgRef.current));
// Fonction pour convertir le markdown en HTML
const renderMarkdown = React.useCallback((text: string) => {
let html = text;
// Gras
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Italique
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Liens
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>');
// Listes - d'abord identifier les blocs de listes
const lines = html.split('\n');
let inList = false;
const processedLines: string[] = [];
lines.forEach((line) => {
if (line.trim().startsWith('• ')) {
if (!inList) {
processedLines.push('<ul class="list-disc list-inside space-y-0.5">');
inList = true;
}
processedLines.push(`<li class="ml-4">${line.trim().substring(2)}</li>`);
} else {
if (inList) {
processedLines.push('</ul>');
inList = false;
}
processedLines.push(line);
}
});
if (inList) {
processedLines.push('</ul>');
}
html = processedLines.join('\n');
// Retours à la ligne (sauf dans les listes)
html = html.replace(/\n(?![^\n]*<\/?(ul|li))/g, '<br />');
return html;
}, []);
// Fonction pour créer le contenu HTML du tooltip avec titre et voyant
const createTooltipContent = React.useCallback((title: string, description: string, color: string) => {
const colorClasses = getColorClasses(color);
return `
<div class="space-y-3">
<div class="flex items-center gap-2.5 pb-2 border-b border-slate-200">
<span class="inline-block w-3 h-3 rounded-full ${colorClasses}"></span>
<span class="font-semibold text-slate-900">${title}</span>
</div>
<div class="text-sm text-slate-700 leading-relaxed">
${renderMarkdown(description)}
</div>
</div>
`;
}, [getColorClasses, renderMarkdown]);
// Charger les données de statut au montage
React.useEffect(() => {
loadStatusData();
}, [loadStatusData]);
React.useEffect(() => {
recomputeTrunc();
const onResize = () => recomputeTrunc();
window.addEventListener('resize', onResize);
const id = window.setInterval(recomputeTrunc, 1500);
return () => { window.removeEventListener('resize', onResize); window.clearInterval(id); };
}, [recomputeTrunc]);
React.useEffect(() => {
const update = () => {
if (paletteRef.current) setPaletteH(paletteRef.current.offsetHeight);
@ -146,7 +198,8 @@ export default function Header({ clientInfo, isStaff }: {
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
}, [statusData]); // Re-calculer quand le statusData change
return (
<header className="sticky top-0 z-40 h-[var(--header-h)] border-b bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60">
<div className="h-full px-4 flex items-center gap-3">
@ -160,71 +213,110 @@ export default function Header({ clientInfo, isStaff }: {
>
<Menu className="w-5 h-5 text-slate-700" />
</button>
<img
src="/odentas-logo.png"
alt="Odentas"
className="w-20 h-20 rounded-lg object-contain"
/>
<div className="h-6 w-px bg-slate-200"/>
<span className="font-semibold">Espace Paie Odentas</span>
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<img
src="/odentas-logo.png"
alt="Odentas"
className="w-20 h-20 rounded-lg object-contain"
/>
<div className="h-6 w-px bg-slate-200"/>
<span className="font-semibold">Espace Paie Odentas</span>
</Link>
</div>
{/* Centre: card d'état (dynamique) */}
<div className="hidden md:flex flex-1 justify-center">
<div className="hidden md:flex flex-1 justify-center items-center min-w-0 mx-2">
<div
className={`relative w-full max-w-[420px] rounded-xl border bg-white/70 shadow-sm overflow-hidden grid grid-rows-2 transition-all duration-150 ${
ref={paletteRef}
className={`relative w-full max-w-[420px] h-fit rounded-xl border bg-white/70 shadow-sm overflow-hidden grid grid-rows-3 transition-all duration-150 ${
isStaff ? 'cursor-pointer hover:bg-white/80' : ''
}`}
style={{ height: expandSection ? undefined : (paletteH ? `${paletteH}px` : undefined) }}
onClick={handleCardClick}
title={isStaff ? 'Cliquer pour modifier les statuts' : undefined}
>
<div
className="px-3 py-1.5 text-xs flex items-center gap-2 border-b border-slate-200/70"
onMouseEnter={() => topTrunc && setExpandSection('top')}
onMouseLeave={() => setExpandSection(null)}
title={statusData.status_top_text}
>
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 ${getColorClasses(statusData.status_top_color)}`} aria-hidden />
<span
ref={topMsgRef}
className={
`${getTextColorClasses(statusData.status_top_color)} ${expandSection==='top' ? 'whitespace-normal' : 'truncate whitespace-nowrap overflow-hidden'}`
}
>{statusData.status_top_text}</span>
{expandSection==='top' && topTrunc && (
<div className="absolute left-full ml-2 top-[25%] -translate-y-1/2 z-50">
<div className="rounded-lg border bg-white shadow p-2 text-xs text-slate-700 max-w-[360px]">
{statusData.status_top_text}
</div>
{/* Ligne 1 : Services Odentas */}
{statusData.status_top_description ? (
<Tooltip
content={createTooltipContent('Services Odentas', statusData.status_top_description, statusData.status_top_color)}
side="bottom"
allowHTML={true}
>
<div className="px-3 py-1.5 text-xs flex items-center gap-2 border-b border-slate-200/70 min-w-0 transition-colors hover:bg-slate-50/50 cursor-help">
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_top_color)}`} aria-hidden />
<span
className={`${getTextColorClasses(statusData.status_top_color)} truncate block min-w-0 flex-1`}
>
Services Odentas : {statusData.status_top_text}
</span>
</div>
)}
</div>
<div
className="px-3 py-1.5 text-xs flex items-center gap-2"
onMouseEnter={() => botTrunc && setExpandSection('bottom')}
onMouseLeave={() => setExpandSection(null)}
title={statusData.status_bottom_text}
>
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 ${getColorClasses(statusData.status_bottom_color)}`} aria-hidden />
<span
ref={botMsgRef}
className={
`${getTextColorClasses(statusData.status_bottom_color)} ${expandSection==='bottom' ? 'whitespace-normal' : 'truncate whitespace-nowrap overflow-hidden'}`
}
>{statusData.status_bottom_text}</span>
{expandSection==='bottom' && botTrunc && (
<div className="absolute left-full ml-2 top-[75%] -translate-y-1/2 z-50">
<div className="rounded-lg border bg-white shadow p-2 text-xs text-slate-700 max-w-[360px]">
{statusData.status_bottom_text}
</div>
</Tooltip>
) : (
<div className="px-3 py-1.5 text-xs flex items-center gap-2 border-b border-slate-200/70 min-w-0">
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_top_color)}`} aria-hidden />
<span
className={`${getTextColorClasses(statusData.status_top_color)} truncate block min-w-0 flex-1`}
>
Services Odentas : {statusData.status_top_text}
</span>
</div>
)}
{/* Ligne 2 : Caisses & orga. */}
{statusData.status_middle_description ? (
<Tooltip
content={createTooltipContent('Caisses & organismes', statusData.status_middle_description, statusData.status_middle_color)}
side="bottom"
allowHTML={true}
>
<div className="px-3 py-1.5 text-xs flex items-center gap-2 border-b border-slate-200/70 min-w-0 transition-colors hover:bg-slate-50/50 cursor-help">
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_middle_color)}`} aria-hidden />
<span
className={`${getTextColorClasses(statusData.status_middle_color)} truncate block min-w-0 flex-1`}
>
{statusData.status_middle_text && `Caisses & orga. : ${statusData.status_middle_text}`}
</span>
</div>
)}
</div>
</Tooltip>
) : (
<div className="px-3 py-1.5 text-xs flex items-center gap-2 border-b border-slate-200/70 min-w-0">
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_middle_color)}`} aria-hidden />
<span
className={`${getTextColorClasses(statusData.status_middle_color)} truncate block min-w-0 flex-1`}
>
{statusData.status_middle_text && `Caisses & orga. : ${statusData.status_middle_text}`}
</span>
</div>
)}
{/* Ligne 3 : Actus */}
{statusData.status_bottom_description ? (
<Tooltip
content={createTooltipContent('Actus', statusData.status_bottom_description, statusData.status_bottom_color)}
side="bottom"
allowHTML={true}
>
<div className="px-3 py-1.5 text-xs flex items-center gap-2 min-w-0 transition-colors hover:bg-slate-50/50 cursor-help">
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_bottom_color)}`} aria-hidden />
<span
className={`${getTextColorClasses(statusData.status_bottom_color)} truncate block min-w-0 flex-1`}
>
{statusData.status_bottom_text && `Actus : ${statusData.status_bottom_text}`}
</span>
</div>
</Tooltip>
) : (
<div className="px-3 py-1.5 text-xs flex items-center gap-2 min-w-0">
<span className={`inline-block w-2.5 h-2.5 rounded-full ring-2 flex-shrink-0 ${getColorClasses(statusData.status_bottom_color)}`} aria-hidden />
<span
className={`${getTextColorClasses(statusData.status_bottom_color)} truncate block min-w-0 flex-1`}
>
{statusData.status_bottom_text && `Actus : ${statusData.status_bottom_text}`}
</span>
</div>
)}
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<button
ref={paletteRef}
onClick={() => window.dispatchEvent(new CustomEvent("open-global-search"))}
className="hidden sm:flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left text-black shadow-sm bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-200 hover:from-indigo-300 hover:via-purple-300 hover:to-pink-300 transition-colors"
aria-label="Palette de Commandes (⌘K ou Ctrl+K)"

View file

@ -0,0 +1,168 @@
"use client";
import React, { useRef } from 'react';
import { Bold, Italic, List, Link as LinkIcon } from 'lucide-react';
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
maxLength?: number;
}
export default function RichTextEditor({
value,
onChange,
placeholder = "Saisissez votre texte...",
maxLength = 1000
}: RichTextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const insertMarkdown = (before: string, after: string = '') => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = value.substring(start, end);
const newText = value.substring(0, start) + before + selectedText + after + value.substring(end);
onChange(newText);
// Restaurer la sélection après le rendu
setTimeout(() => {
textarea.focus();
const newCursorPos = start + before.length + selectedText.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
};
const handleBold = () => insertMarkdown('**', '**');
const handleItalic = () => insertMarkdown('*', '*');
const handleList = () => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const newText = value.substring(0, lineStart) + '• ' + value.substring(lineStart);
onChange(newText);
};
const handleLink = () => {
const url = prompt('Entrez l\'URL du lien :');
if (url) {
insertMarkdown('[', `](${url})`);
}
};
// Fonction pour prévisualiser le markdown en HTML simple
const renderPreview = (text: string) => {
let html = text;
// Gras
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Italique
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Liens
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>');
// Listes - d'abord identifier les blocs de listes
const lines = html.split('\n');
let inList = false;
const processedLines: string[] = [];
lines.forEach((line) => {
if (line.trim().startsWith('• ')) {
if (!inList) {
processedLines.push('<ul class="list-disc list-inside space-y-0.5 my-2">');
inList = true;
}
processedLines.push(`<li class="ml-4">${line.trim().substring(2)}</li>`);
} else {
if (inList) {
processedLines.push('</ul>');
inList = false;
}
processedLines.push(line);
}
});
if (inList) {
processedLines.push('</ul>');
}
html = processedLines.join('\n');
// Retours à la ligne (sauf dans les listes)
html = html.replace(/\n(?![^\n]*<\/?(ul|li))/g, '<br />');
return html;
};
return (
<div className="space-y-2">
{/* Barre d'outils */}
<div className="flex items-center gap-1 p-2 bg-slate-50 border border-slate-300 rounded-t-lg">
<button
type="button"
onClick={handleBold}
className="p-2 hover:bg-slate-200 rounded transition-colors"
title="Gras (Ctrl+B)"
>
<Bold className="w-4 h-4 text-slate-700" />
</button>
<button
type="button"
onClick={handleItalic}
className="p-2 hover:bg-slate-200 rounded transition-colors"
title="Italique (Ctrl+I)"
>
<Italic className="w-4 h-4 text-slate-700" />
</button>
<button
type="button"
onClick={handleList}
className="p-2 hover:bg-slate-200 rounded transition-colors"
title="Liste à puces"
>
<List className="w-4 h-4 text-slate-700" />
</button>
<button
type="button"
onClick={handleLink}
className="p-2 hover:bg-slate-200 rounded transition-colors"
title="Insérer un lien"
>
<LinkIcon className="w-4 h-4 text-slate-700" />
</button>
<div className="ml-auto text-xs text-slate-500">
{value.length}/{maxLength} caractères
</div>
</div>
{/* Zone de texte */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
maxLength={maxLength}
className="w-full px-3 py-2 border border-slate-300 border-t-0 rounded-b-lg bg-white text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[120px] resize-y font-mono text-sm"
/>
{/* Prévisualisation */}
{value && (
<div className="p-3 bg-slate-50 border border-slate-300 rounded-lg">
<div className="text-xs font-medium text-slate-600 mb-2">Aperçu :</div>
<div
className="text-sm text-slate-700 prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: renderPreview(value) }}
/>
</div>
)}
</div>
);
}

View file

@ -322,7 +322,7 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
</span>
) : (
<Link
href="/compte/securite"
href="/securite"
prefetch={false}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-indigo-50 text-indigo-700 hover:bg-indigo-100 w-full justify-center"
title="Sécurité du compte"

View file

@ -3,12 +3,18 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Save, AlertCircle } from 'lucide-react';
import RichTextEditor from './RichTextEditor';
interface StatusData {
status_top_text: string;
status_middle_text: string;
status_bottom_text: string;
status_top_color: string;
status_middle_color: string;
status_bottom_color: string;
status_top_description?: string;
status_middle_description?: string;
status_bottom_description?: string;
}
interface StatusEditModalProps {
@ -38,14 +44,19 @@ export default function StatusEditModal({
}: StatusEditModalProps) {
const [formData, setFormData] = useState<StatusData>({
status_top_text: '',
status_middle_text: '',
status_bottom_text: '',
status_top_color: 'emerald',
status_bottom_color: 'sky'
status_middle_color: 'blue',
status_bottom_color: 'sky',
status_top_description: '',
status_middle_description: '',
status_bottom_description: ''
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'top' | 'bottom'>('top');
const [activeTab, setActiveTab] = useState<'top' | 'middle' | 'bottom'>('top');
const [mounted, setMounted] = useState(false);
// S'assurer que le composant est monté côté client
@ -58,9 +69,14 @@ export default function StatusEditModal({
if (initialData) {
setFormData({
status_top_text: initialData.status_top_text || '',
status_middle_text: initialData.status_middle_text || '',
status_bottom_text: initialData.status_bottom_text || '',
status_top_color: initialData.status_top_color || 'emerald',
status_bottom_color: initialData.status_bottom_color || 'sky'
status_middle_color: initialData.status_middle_color || 'blue',
status_bottom_color: initialData.status_bottom_color || 'sky',
status_top_description: initialData.status_top_description || '',
status_middle_description: initialData.status_middle_description || '',
status_bottom_description: initialData.status_bottom_description || ''
});
}
}, [initialData]);
@ -121,13 +137,21 @@ export default function StatusEditModal({
<div className="p-6 space-y-3">
<h3 className="font-medium text-slate-900">Aperçu</h3>
<div className="border rounded-lg p-4 bg-slate-50">
<div className="grid grid-rows-2 rounded-lg border bg-white overflow-hidden">
<div className="grid grid-rows-3 rounded-lg border bg-white overflow-hidden">
<div className="px-3 py-2 text-sm flex items-center gap-2 border-b border-slate-200">
<span
className={`inline-block w-2.5 h-2.5 rounded-full ${getColorPreview(formData.status_top_color)} ring-2 ring-white/70`}
/>
<span className="text-slate-700 truncate">
{formData.status_top_text || 'Texte du voyant du haut'}
{formData.status_top_text && `Services Odentas : ${formData.status_top_text}`}
</span>
</div>
<div className="px-3 py-2 text-sm flex items-center gap-2 border-b border-slate-200">
<span
className={`inline-block w-2.5 h-2.5 rounded-full ${getColorPreview(formData.status_middle_color)} ring-2 ring-white/70`}
/>
<span className="text-slate-700 truncate">
{formData.status_middle_text && `Caisses & orga. : ${formData.status_middle_text}`}
</span>
</div>
<div className="px-3 py-2 text-sm flex items-center gap-2">
@ -135,7 +159,7 @@ export default function StatusEditModal({
className={`inline-block w-2.5 h-2.5 rounded-full ${getColorPreview(formData.status_bottom_color)} ring-2 ring-white/70`}
/>
<span className="text-slate-700 truncate">
{formData.status_bottom_text || 'Texte du voyant du bas'}
{formData.status_bottom_text && `Actus : ${formData.status_bottom_text}`}
</span>
</div>
</div>
@ -154,7 +178,18 @@ export default function StatusEditModal({
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
🔴 Voyant du haut
Services Odentas
</button>
<button
type="button"
onClick={() => setActiveTab('middle')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'middle'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
Caisses & orga.
</button>
<button
type="button"
@ -165,7 +200,7 @@ export default function StatusEditModal({
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
🔵 Voyant du bas
Actus
</button>
</div>
</div>
@ -176,7 +211,7 @@ export default function StatusEditModal({
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Texte du voyant du haut
Titre (Services Odentas)
</label>
<input
type="text"
@ -188,13 +223,28 @@ export default function StatusEditModal({
required
/>
<p className="text-xs text-slate-500">
{(formData.status_top_text || '').length}/200 caractères
{(formData.status_top_text || '').length}/200 caractères - Affiché dans la card du header
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Couleur du voyant du haut
Description détaillée (affichée au survol)
</label>
<RichTextEditor
value={formData.status_top_description || ''}
onChange={(value) => handleInputChange('status_top_description', value)}
placeholder="Ajoutez des détails, des liens, etc. Supporte le markdown (gras, italique, liens, listes)."
maxLength={1000}
/>
<p className="text-xs text-slate-500">
Cette description sera affichée dans le tooltip au survol de la ligne
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Couleur du voyant (Services Odentas)
</label>
<div className="grid grid-cols-3 gap-2">
{AVAILABLE_COLORS.map((color) => (
@ -217,11 +267,71 @@ export default function StatusEditModal({
</div>
)}
{activeTab === 'middle' && (
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Titre (Caisses & orga.)
</label>
<input
type="text"
value={formData.status_middle_text || ''}
onChange={(e) => handleInputChange('status_middle_text', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-white text-slate-900 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ex: Tous nos services sont opérationnels"
maxLength={200}
required
/>
<p className="text-xs text-slate-500">
{(formData.status_middle_text || '').length}/200 caractères - Affiché dans la card du header
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Description détaillée (affichée au survol)
</label>
<RichTextEditor
value={formData.status_middle_description || ''}
onChange={(value) => handleInputChange('status_middle_description', value)}
placeholder="Ajoutez des détails, des liens, etc. Supporte le markdown (gras, italique, liens, listes)."
maxLength={1000}
/>
<p className="text-xs text-slate-500">
Cette description sera affichée dans le tooltip au survol de la ligne
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Couleur du voyant (Caisses & orga.)
</label>
<div className="grid grid-cols-3 gap-2">
{AVAILABLE_COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => handleInputChange('status_middle_color', color.value)}
className={`flex items-center gap-2 p-2 rounded-lg border text-sm transition-colors ${
formData.status_middle_color === color.value
? 'border-blue-500 bg-blue-50'
: 'border-slate-300 hover:bg-slate-50'
}`}
>
<span className={`w-4 h-4 rounded-full ${color.class}`} />
<span className="text-slate-700">{color.label}</span>
</button>
))}
</div>
</div>
</div>
)}
{activeTab === 'bottom' && (
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Texte du voyant du bas
Titre (Actus)
</label>
<input
type="text"
@ -233,13 +343,28 @@ export default function StatusEditModal({
required
/>
<p className="text-xs text-slate-500">
{(formData.status_bottom_text || '').length}/200 caractères
{(formData.status_bottom_text || '').length}/200 caractères - Affiché dans la card du header
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Couleur du voyant du bas
Description détaillée (affichée au survol)
</label>
<RichTextEditor
value={formData.status_bottom_description || ''}
onChange={(value) => handleInputChange('status_bottom_description', value)}
placeholder="Ajoutez des détails, des liens, etc. Supporte le markdown (gras, italique, liens, listes)."
maxLength={1000}
/>
<p className="text-xs text-slate-500">
Cette description sera affichée dans le tooltip au survol de la ligne
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Couleur du voyant (Actus)
</label>
<div className="grid grid-cols-3 gap-2">
{AVAILABLE_COLORS.map((color) => (

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { X, Trash2, Edit3, Download, Loader2, ExternalLink } from "lucide-react";
import { X, Trash2, Edit3, Loader2, ExternalLink } from "lucide-react";
import { toast } from "sonner";
interface DocumentViewerModalProps {
@ -209,22 +209,15 @@ export default function DocumentViewerModal({
>
<Edit3 className="size-5 text-slate-600" />
</button>
<a
href={document.downloadUrl}
download
className="p-2 hover:bg-slate-200 rounded-lg transition-colors"
title="Télécharger"
>
<Download className="size-5 text-slate-600" />
</a>
<a
href={document.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 hover:bg-slate-200 rounded-lg transition-colors"
title="Ouvrir dans un nouvel onglet"
className="flex items-center gap-2 px-3 py-2 hover:bg-slate-200 rounded-lg transition-colors text-slate-700 text-sm font-medium"
title="Ouvrir dans un nouvel onglet / Télécharger"
>
<ExternalLink className="size-5 text-slate-600" />
<ExternalLink className="size-4 text-slate-600" />
Nouvel onglet / Télécharger
</a>
<button
onClick={() => setShowDeleteConfirm(true)}
@ -269,11 +262,12 @@ export default function DocumentViewerModal({
</p>
<a
href={document.downloadUrl}
download
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<Download className="size-4" />
Télécharger le fichier
<ExternalLink className="size-4" />
Ouvrir dans un nouvel onglet / Télécharger
</a>
</div>
</div>

View file

@ -757,6 +757,52 @@ export default function ContractEditor({
(payslips ?? []).map((p) => ({ ...p }))
);
// Déterminer le type de contrat pour l'affichage des paies
const contractType = useMemo(() => {
const typeContrat = contract.type_de_contrat || "";
const isMultiMois = contract.multi_mois === true || contract.multi_mois === "true" || contract.multi_mois === "Oui";
// RG = "CDD de droit commun" ou "CDI"
if (typeContrat === "CDD de droit commun" || typeContrat === "CDI") {
return "RG";
}
// CDDU multi-mois
if (typeContrat === "CDD d'usage" && isMultiMois) {
return "CDDU_MULTI";
}
// CDDU mono-mois (par défaut)
return "CDDU_MONO";
}, [contract.type_de_contrat, contract.multi_mois]);
// État pour la pagination des paies (pour RG et CDDU multi)
const [payslipPage, setPayslipPage] = useState(0);
const payslipsPerPage = 6;
// Trier et paginer les paies si nécessaire
const displayedPayslips = useMemo(() => {
const needsCompactView = contractType === "RG" || contractType === "CDDU_MULTI";
// Trier par numéro de paie décroissant (plus récent en premier)
const sorted = [...rows].sort((a, b) => {
const numA = a.pay_number || 0;
const numB = b.pay_number || 0;
return numB - numA; // Décroissant
});
if (needsCompactView && sorted.length > payslipsPerPage) {
const start = payslipPage * payslipsPerPage;
const end = start + payslipsPerPage;
return sorted.slice(start, end);
}
return sorted;
}, [rows, contractType, payslipPage]);
const totalPayslipPages = Math.ceil(rows.length / payslipsPerPage);
const needsPagination = (contractType === "RG" || contractType === "CDDU_MULTI") && rows.length > payslipsPerPage;
const [open, setOpen] = useState(false);
// Search hooks
@ -2797,19 +2843,39 @@ export default function ContractEditor({
<Card className="rounded-3xl">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CalendarRange className="size-5" /> Paies ({rows.length})
<CardTitle className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CalendarRange className="size-5" /> Paies ({rows.length})
</div>
{contractType === "CDDU_MONO" && (
<Button
onClick={() => handleOpenPayslipModal()}
variant="outline"
size="sm"
className="rounded-2xl"
>
<FilePlus2 className="size-4 mr-2" />
Ajouter une paie
</Button>
)}
</div>
<Button
onClick={() => handleOpenPayslipModal()}
variant="outline"
size="sm"
className="rounded-2xl"
>
<FilePlus2 className="size-4 mr-2" />
Ajouter une paie
</Button>
{(contractType === "RG" || contractType === "CDDU_MULTI") && (
<div className="flex items-center justify-between">
<span className="text-sm font-normal text-muted-foreground bg-slate-100 px-3 py-1.5 rounded-full">
{contractType === "RG" ? "Régime Général" : "CDDU Multi-mois"}
</span>
<Button
onClick={() => handleOpenPayslipModal()}
variant="outline"
size="sm"
className="rounded-2xl"
>
<FilePlus2 className="size-4 mr-2" />
Ajouter une paie
</Button>
</div>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@ -2820,18 +2886,118 @@ export default function ContractEditor({
<p className="text-sm">Cliquez sur "Ajouter une paie" pour commencer</p>
</div>
) : (
<div className="space-y-3">
{rows.map((payslip, i) => (
<PayslipCard
key={payslip.id ?? i}
payslip={payslip}
index={i}
contractId={contract.id}
onClick={() => handleOpenPayslipModal(payslip)}
onUploadComplete={handlePayslipUploadComplete}
/>
))}
</div>
<>
{contractType === "CDDU_MONO" ? (
// Affichage normal pour CDDU mono-mois
<div className="space-y-3">
{displayedPayslips.map((payslip, i) => (
<PayslipCard
key={payslip.id ?? i}
payslip={payslip}
index={i}
contractId={contract.id}
onClick={() => handleOpenPayslipModal(payslip)}
onUploadComplete={handlePayslipUploadComplete}
/>
))}
</div>
) : (
// Affichage compact pour RG et CDDU multi-mois
<>
<div className="space-y-2">
{displayedPayslips.map((payslip, i) => (
<div
key={payslip.id ?? i}
onClick={() => handleOpenPayslipModal(payslip)}
className="p-3 border rounded-xl hover:bg-slate-50 cursor-pointer transition-colors"
>
<div className="flex items-start gap-6">
<div className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-medium shrink-0">
#{payslip.pay_number || (payslipPage * payslipsPerPage + i + 1)}
</div>
<div className="flex-1 min-w-0">
<div className="font-normal text-sm text-gray-600 whitespace-nowrap mb-3">
{payslip.period_start && payslip.period_end ? (
`${new Date(payslip.period_start).toLocaleDateString('fr-FR', { month: 'short', year: 'numeric' })}`
) : (
"Période non définie"
)}
</div>
<div className="flex gap-1.5 flex-nowrap">
{payslip.storage_path && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-50 text-green-600 whitespace-nowrap">
<CheckCircle2 className="size-3 mr-1" />
PDF
</span>
)}
{payslip.processed ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-50 text-green-600 whitespace-nowrap">
Traitée
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-50 text-red-600 whitespace-nowrap">
À traiter
</span>
)}
{/* Statut AEM */}
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap ${
payslip.aem_status === 'OK'
? 'bg-green-50 text-green-600'
: payslip.aem_status === 'KO'
? 'bg-red-50 text-red-600'
: 'bg-gray-50 text-gray-600'
}`}>
AEM: {payslip.aem_status || 'N/A'}
</span>
</div>
</div>
<div className="flex gap-10 text-right shrink-0 ml-10">
<div className="leading-tight">
<div className="font-semibold text-sm">
{payslip.gross_amount ? `${parseFloat(payslip.gross_amount).toFixed(2)}` : "—"}
</div>
<div className="text-xs text-gray-500">Brut</div>
</div>
<div className="leading-tight">
<div className="font-semibold text-sm">
{payslip.net_after_withholding ? `${parseFloat(payslip.net_after_withholding).toFixed(2)}` : "—"}
</div>
<div className="text-xs text-gray-500">Net à payer</div>
</div>
</div>
</div>
</div>
))}
</div>
{needsPagination && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
onClick={() => setPayslipPage(prev => Math.max(0, prev - 1))}
disabled={payslipPage === 0}
variant="outline"
size="sm"
className="rounded-lg"
>
Précédent
</Button>
<span className="text-sm text-muted-foreground px-3">
Page {payslipPage + 1} sur {totalPayslipPages}
</span>
<Button
onClick={() => setPayslipPage(prev => Math.min(totalPayslipPages - 1, prev + 1))}
disabled={payslipPage >= totalPayslipPages - 1}
variant="outline"
size="sm"
className="rounded-lg"
>
Suivant
</Button>
</div>
)}
</>
)}
</>
)}
</CardContent>
</Card>

View file

@ -113,43 +113,46 @@ export function PayslipCard({ payslip, index, contractId, onClick, onUploadCompl
return;
}
// Si on a un PDF, générer une URL pré-signée et l'ouvrir
if (hasPdf && payslip.storage_path) {
e.preventDefault();
e.stopPropagation();
setIsOpeningPdf(true);
try {
const response = await fetch('/api/s3-presigned', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: payslip.storage_path
})
});
// Appeler le onClick par défaut (pour ouvrir le modal d'édition)
if (onClick) {
onClick();
}
};
if (!response.ok) {
throw new Error('Erreur lors de la génération de l\'URL');
}
const result = await response.json();
// Ouvrir le fichier dans un nouvel onglet
window.open(result.url, '_blank');
} catch (error) {
console.error("Erreur ouverture PDF:", error);
toast.error("Erreur lors de l'accès au fichier");
} finally {
setIsOpeningPdf(false);
}
const handleOpenPdf = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!payslip.storage_path || isOpeningPdf) {
return;
}
// Sinon, appeler le onClick par défaut (pour ouvrir le modal d'édition)
if (onClick) {
onClick();
setIsOpeningPdf(true);
try {
const response = await fetch('/api/s3-presigned', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: payslip.storage_path
})
});
if (!response.ok) {
throw new Error('Erreur lors de la génération de l\'URL');
}
const result = await response.json();
// Ouvrir le fichier dans un nouvel onglet
window.open(result.url, '_blank');
} catch (error) {
console.error("Erreur ouverture PDF:", error);
toast.error("Erreur lors de l'accès au fichier");
} finally {
setIsOpeningPdf(false);
}
};
@ -169,8 +172,8 @@ export function PayslipCard({ payslip, index, contractId, onClick, onUploadCompl
? 'border-blue-500 bg-blue-50 border-2'
: hasPdf
? 'border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer'
: 'hover:bg-gray-50'
} ${isOpeningPdf ? 'opacity-50 cursor-wait' : !isUploading && hasPdf ? 'cursor-pointer' : ''}`}
: 'hover:bg-gray-50 cursor-pointer'
} ${isOpeningPdf ? 'opacity-50' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
@ -198,7 +201,11 @@ export function PayslipCard({ payslip, index, contractId, onClick, onUploadCompl
{/* Indicateur de bulletin PDF */}
{hasPdf && (
<div className="flex items-center gap-2 bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-medium">
<button
onClick={handleOpenPdf}
disabled={isOpeningPdf}
className="flex items-center gap-2 bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-medium hover:bg-green-200 transition-colors disabled:opacity-50 disabled:cursor-wait"
>
<CheckCircle2 className="size-4" />
{isOpeningPdf ? (
<span className="flex items-center gap-1">
@ -207,11 +214,11 @@ export function PayslipCard({ payslip, index, contractId, onClick, onUploadCompl
</span>
) : (
<span className="flex items-center gap-1">
Bulletin uploadé
Ouvrir le PDF
<ExternalLink className="size-3" />
</span>
)}
</div>
</button>
)}
</div>

View file

@ -10,9 +10,10 @@ interface TooltipProps {
className?: string
side?: 'top' | 'bottom' | 'left' | 'right'
asChild?: boolean
allowHTML?: boolean
}
export function Tooltip({ children, content, className, side = 'top', asChild = false }: TooltipProps) {
export function Tooltip({ children, content, className, side = 'top', asChild = false, allowHTML = false }: TooltipProps) {
const [isVisible, setIsVisible] = React.useState(false)
const [triggerRect, setTriggerRect] = React.useState<DOMRect | null>(null)
const triggerRef = React.useRef<HTMLDivElement>(null)
@ -78,10 +79,10 @@ export function Tooltip({ children, content, className, side = 'top', asChild =
}
const arrowClasses = {
top: 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 border-t-4 border-x-transparent border-x-4 border-b-0',
bottom: 'bottom-full left-1/2 transform -translate-x-1/2 border-b-gray-900 border-b-4 border-x-transparent border-x-4 border-t-0',
left: 'left-full top-1/2 transform -translate-y-1/2 border-l-gray-900 border-l-4 border-y-transparent border-y-4 border-r-0',
right: 'right-full top-1/2 transform -translate-y-1/2 border-r-gray-900 border-r-4 border-y-transparent border-y-4 border-l-0'
top: 'top-full left-1/2 transform -translate-x-1/2 border-t-white border-t-4 border-x-transparent border-x-4 border-b-0',
bottom: 'bottom-full left-1/2 transform -translate-x-1/2 border-b-white border-b-4 border-x-transparent border-x-4 border-t-0',
left: 'left-full top-1/2 transform -translate-y-1/2 border-l-white border-l-4 border-y-transparent border-y-4 border-r-0',
right: 'right-full top-1/2 transform -translate-y-1/2 border-r-white border-r-4 border-y-transparent border-y-4 border-l-0'
}
return (
@ -101,12 +102,17 @@ export function Tooltip({ children, content, className, side = 'top', asChild =
<div
style={getTooltipStyle()}
className={cn(
"px-3 py-2 text-sm text-white bg-gray-900 rounded-lg shadow-xl whitespace-nowrap pointer-events-none relative",
"animate-in fade-in-0 zoom-in-95 duration-150",
"px-4 py-3 text-sm rounded-xl shadow-2xl pointer-events-none relative max-w-md min-w-[280px]",
"bg-white text-slate-900 border border-slate-200",
"animate-in fade-in-0 zoom-in-95 duration-200",
className
)}
>
{content}
{allowHTML ? (
<div dangerouslySetInnerHTML={{ __html: content }} className="prose prose-sm max-w-none" />
) : (
content
)}
<div className={cn("absolute w-0 h-0", arrowClasses[side])} />
</div>,
document.body

View file

@ -383,7 +383,7 @@ export async function sendPasswordCreatedEmail(
status: 'Créé',
eventDate: data.eventDate || new Date().toLocaleString('fr-FR'),
platform: data.platform || 'Odentas Paie',
ctaUrl: `${process.env.NEXT_PUBLIC_BASE_URL || 'https://paie.odentas.fr'}/compte/securite`,
ctaUrl: `${process.env.NEXT_PUBLIC_BASE_URL || 'https://paie.odentas.fr'}/securite`,
};
await sendUniversalEmailV2({

View file

@ -0,0 +1,24 @@
-- Migration pour ajouter les descriptions détaillées aux statuts du header
-- Date: 2025-10-31
-- Ajouter les colonnes de description pour chaque statut
ALTER TABLE maintenance_status
ADD COLUMN IF NOT EXISTS status_top_description TEXT,
ADD COLUMN IF NOT EXISTS status_middle_description TEXT,
ADD COLUMN IF NOT EXISTS status_bottom_description TEXT;
-- Ajouter des commentaires pour documenter les nouvelles colonnes
COMMENT ON COLUMN maintenance_status.status_top_description IS 'Description détaillée (HTML/Markdown) affichée dans le tooltip du statut du haut';
COMMENT ON COLUMN maintenance_status.status_middle_description IS 'Description détaillée (HTML/Markdown) affichée dans le tooltip du statut du milieu';
COMMENT ON COLUMN maintenance_status.status_bottom_description IS 'Description détaillée (HTML/Markdown) affichée dans le tooltip du statut du bas';
-- Initialiser les descriptions avec les textes existants pour une transition douce
UPDATE maintenance_status
SET
status_top_description = COALESCE(status_top_text, ''),
status_middle_description = COALESCE(status_middle_text, ''),
status_bottom_description = COALESCE(status_bottom_text, '')
WHERE
status_top_description IS NULL
OR status_middle_description IS NULL
OR status_bottom_description IS NULL;