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:
parent
6485db4a75
commit
73e914a303
16 changed files with 1204 additions and 486 deletions
|
|
@ -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
300
app/(app)/securite/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
:root{
|
||||
--brand:#f5c542;
|
||||
--header-h:64px;
|
||||
--header-h:100px;
|
||||
--sidebar-w:274px;
|
||||
}
|
||||
html,body{ height:100%; }
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
168
components/RichTextEditor.tsx
Normal file
168
components/RichTextEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
24
supabase/migrations/0004_add_status_descriptions.sql
Normal file
24
supabase/migrations/0004_add_status_descriptions.sql
Normal 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;
|
||||
Loading…
Reference in a new issue