feat(ui): GlossaryTerm — tooltip de définition sur DSO / LME / Mise en demeure

Quand un terme métier apparaît (DSO moyen, LME, mise en demeure…), un
petit astérisque rubis à côté indique qu'il est hoverable. Au hover/focus
clavier, une popover s'affiche avec la définition courte (qui ce fait,
pourquoi ça compte, repère LME 30j).

Implémentation :
- components/ui/GlossaryTerm.tsx : wrap n'importe quel ReactNode + définition,
  utilise @radix-ui/react-tooltip (déjà dans la stack pour Dialog), Asterisk
  Lucide en marker, underline pointillée subtile pour signaler "interactif"
- lib/glossary.tsx : définitions centralisées (DSO, LME, mise en demeure,
  encaissé, rubis) — single source of truth, ton produit cohérent
- KpiCard.label / SummaryCard.label passent à React.ReactNode pour
  accepter le wrapping
- Wiring : "DSO moyen" sur dashboard (KpiCard + titre du chart) et /insights
  (récap + titre du chart). LME aussi taggée dans le sous-titre du DSO chart.

Aucun nouveau dep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 10:15:44 +02:00
parent 2d3766cc3d
commit 6eb9ca4120
5 changed files with 152 additions and 7 deletions

View File

@ -15,7 +15,8 @@ import { cn } from "@/lib/utils";
* - On laisse l'utilisateur déclarer l'intent. * - On laisse l'utilisateur déclarer l'intent.
*/ */
type KpiCardProps = { type KpiCardProps = {
label: string; /** Texte ou node — accepte un GlossaryTerm si la métrique a une définition. */
label: React.ReactNode;
value: string; value: string;
delta?: string; delta?: string;
/** Sens du delta affiché (sert juste à colorer subtilement). Default neutral. */ /** Sens du delta affiché (sert juste à colorer subtilement). Default neutral. */

View File

@ -0,0 +1,80 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Asterisk } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Term wrapper affiche un terme avec une petite astérisque cliquable/hoverable
* qui révèle sa définition. Pour le glossaire métier (DSO, LME, mise en demeure).
*
* Pas de tooltip natif (`title=`) parce qu'il s'affiche en gris système et
* casse la DA. Radix Tooltip est déjà dans la stack pour Dialog, on en
* profite ici.
*/
type GlossaryTermProps = {
/** Le mot/expression visible (ex. "DSO moyen"). */
children: React.ReactNode;
/** Définition affichée dans le tooltip. */
definition: React.ReactNode;
/** Côté du tooltip (défaut bottom). */
side?: "top" | "right" | "bottom" | "left";
/** Classe sur le wrapper. */
className?: string;
/** Position de l'astérisque (défaut : after = à droite). */
marker?: "before" | "after";
};
export function GlossaryTerm({
children,
definition,
side = "bottom",
className,
marker = "after",
}: GlossaryTermProps) {
const star = (
<Asterisk
size={9}
strokeWidth={2.5}
aria-hidden="true"
className="inline-block text-rubis align-text-top translate-y-[1px]"
/>
);
return (
<TooltipPrimitive.Provider delayDuration={200}>
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>
<span
className={cn(
"inline-flex items-center gap-0.5 cursor-help",
"underline-offset-4 decoration-dotted decoration-ink-3/40",
"hover:decoration-rubis hover:decoration-solid",
className,
)}
tabIndex={0}
>
{marker === "before" && star}
{children}
{marker === "after" && star}
</span>
</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={side}
sideOffset={6}
collisionPadding={12}
className={cn(
"z-50 max-w-[280px] rounded-default border border-rubis-glow bg-white",
"px-3 py-2 text-[12.5px] leading-snug text-ink-2",
"shadow-card",
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
)}
>
{definition}
<TooltipPrimitive.Arrow className="fill-white" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
);
}

View File

@ -0,0 +1,46 @@
/**
* Glossaire métier définitions partagées par les tooltips.
* Centraliser ici pour pas réécrire la même chose à 3 endroits + garder
* le ton produit cohérent.
*/
export const GLOSSARY = {
dso: (
<>
<strong>DSO</strong> (Days Sales Outstanding) = délai moyen entre
l'émission d'une facture et son paiement. Plus le chiffre est bas,
plus vous êtes payé vite. La norme française B2B (LME) est{" "}
<strong>30 jours</strong>.
</>
),
lme: (
<>
<strong>LME</strong> = loi de modernisation de l'économie (2008).
Elle plafonne les délais de paiement entre entreprises à{" "}
<strong>60 jours</strong> (ou 45 j fin de mois). Au-delà, sanctions
DGCCRF jusqu'à 2 M.
</>
),
miseEnDemeure: (
<>
<strong>Mise en demeure</strong> = relance formelle écrite avec un
délai impératif (8 jours en général). Étape obligatoire avant toute
procédure judiciaire de recouvrement. Sous Rubis, elle est toujours
validée manuellement avant envoi.
</>
),
encaisse: (
<>
<strong>Encaissé</strong> = montant total des factures effectivement
payées sur la période, hors TVA si non collectée. C'est l'argent
qui est arrivé sur votre compte, pas juste facturé.
</>
),
rubis: (
<>
<strong>1 rubis</strong> = 10 minutes de votre temps libérées par
Rubis. À chaque relance envoyée automatiquement et chaque facture
réglée, vous gagnez un rubis.
</>
),
} as const;

View File

@ -9,6 +9,8 @@ import { formatEuros } from "@/lib/format";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow"; import { Eyebrow } from "@/components/ui/Eyebrow";
import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
import { GLOSSARY } from "@/lib/glossary";
import { RubisHero } from "@/components/dashboard/RubisHero"; import { RubisHero } from "@/components/dashboard/RubisHero";
import { KpiCard } from "@/components/dashboard/KpiCard"; import { KpiCard } from "@/components/dashboard/KpiCard";
import { import {
@ -142,7 +144,9 @@ function DashboardPage() {
intent="positive" intent="positive"
/> />
<KpiCard <KpiCard
label="DSO moyen" label={
<GlossaryTerm definition={GLOSSARY.dso}>DSO moyen</GlossaryTerm>
}
value={`${kpis?.dsoDays ?? 0} j`} value={`${kpis?.dsoDays ?? 0} j`}
delta={ delta={
kpis?.dsoDeltaDays && kpis.dsoDeltaDays !== 0 kpis?.dsoDeltaDays && kpis.dsoDeltaDays !== 0
@ -179,7 +183,10 @@ function DashboardPage() {
<Card padding="md" className="flex flex-col"> <Card padding="md" className="flex flex-col">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div> <div>
<Eyebrow tone="ink">DSO · 6 mois</Eyebrow> <Eyebrow tone="ink">
<GlossaryTerm definition={GLOSSARY.dso}>DSO</GlossaryTerm>
{" · 6 mois"}
</Eyebrow>
<p className="mt-1 text-[12.5px] text-ink-3"> <p className="mt-1 text-[12.5px] text-ink-3">
Délai moyen entre émission et paiement. Délai moyen entre émission et paiement.
</p> </p>

View File

@ -8,6 +8,8 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow"; import { Eyebrow } from "@/components/ui/Eyebrow";
import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
import { GLOSSARY } from "@/lib/glossary";
import { EncaisseChart } from "@/components/charts/EncaisseChart"; import { EncaisseChart } from "@/components/charts/EncaisseChart";
import { DsoTrendChart } from "@/components/charts/DsoTrendChart"; import { DsoTrendChart } from "@/components/charts/DsoTrendChart";
import { import {
@ -91,7 +93,12 @@ function InsightsPage() {
value={formatEuros(totalEncaisse)} value={formatEuros(totalEncaisse)}
/> />
<SummaryCard label="Factures payées" value={String(totalPaid)} /> <SummaryCard label="Factures payées" value={String(totalPaid)} />
<SummaryCard label="DSO moyen" value={dsoMoyen > 0 ? `${dsoMoyen} j` : "—"} /> <SummaryCard
label={
<GlossaryTerm definition={GLOSSARY.dso}>DSO moyen</GlossaryTerm>
}
value={dsoMoyen > 0 ? `${dsoMoyen} j` : "—"}
/>
</section> </section>
{/* Encaissé — pleine largeur, chart le plus important */} {/* Encaissé — pleine largeur, chart le plus important */}
@ -109,10 +116,14 @@ function InsightsPage() {
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-5"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-5">
<Card padding="md"> <Card padding="md">
<div className="mb-4"> <div className="mb-4">
<Eyebrow tone="ink">Délai de paiement (DSO)</Eyebrow> <Eyebrow tone="ink">
Délai de paiement (
<GlossaryTerm definition={GLOSSARY.dso}>DSO</GlossaryTerm>)
</Eyebrow>
<p className="mt-1 text-[13px] text-ink-3"> <p className="mt-1 text-[13px] text-ink-3">
Jours moyens entre l'émission et le paiement. La référence Jours moyens entre l'émission et le paiement. La référence
à 30 j est la norme LME pour le B2B. à 30 j est la norme{" "}
<GlossaryTerm definition={GLOSSARY.lme}>LME</GlossaryTerm> pour le B2B.
</p> </p>
</div> </div>
<DsoTrendChart data={ts?.paidByMonth ?? []} height={260} /> <DsoTrendChart data={ts?.paidByMonth ?? []} height={260} />
@ -176,7 +187,7 @@ function RangePicker({
); );
} }
function SummaryCard({ label, value }: { label: string; value: string }) { function SummaryCard({ label, value }: { label: React.ReactNode; value: string }) {
return ( return (
<Card padding="md"> <Card padding="md">
<p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-ink-3"> <p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-ink-3">