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:
parent
2d3766cc3d
commit
6eb9ca4120
@ -15,7 +15,8 @@ import { cn } from "@/lib/utils";
|
||||
* - On laisse l'utilisateur déclarer l'intent.
|
||||
*/
|
||||
type KpiCardProps = {
|
||||
label: string;
|
||||
/** Texte ou node — accepte un GlossaryTerm si la métrique a une définition. */
|
||||
label: React.ReactNode;
|
||||
value: string;
|
||||
delta?: string;
|
||||
/** Sens du delta affiché (sert juste à colorer subtilement). Default neutral. */
|
||||
|
||||
80
apps/web/src/components/ui/GlossaryTerm.tsx
Normal file
80
apps/web/src/components/ui/GlossaryTerm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/web/src/lib/glossary.tsx
Normal file
46
apps/web/src/lib/glossary.tsx
Normal 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;
|
||||
@ -9,6 +9,8 @@ import { formatEuros } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
|
||||
import { GLOSSARY } from "@/lib/glossary";
|
||||
import { RubisHero } from "@/components/dashboard/RubisHero";
|
||||
import { KpiCard } from "@/components/dashboard/KpiCard";
|
||||
import {
|
||||
@ -142,7 +144,9 @@ function DashboardPage() {
|
||||
intent="positive"
|
||||
/>
|
||||
<KpiCard
|
||||
label="DSO moyen"
|
||||
label={
|
||||
<GlossaryTerm definition={GLOSSARY.dso}>DSO moyen</GlossaryTerm>
|
||||
}
|
||||
value={`${kpis?.dsoDays ?? 0} j`}
|
||||
delta={
|
||||
kpis?.dsoDeltaDays && kpis.dsoDeltaDays !== 0
|
||||
@ -179,7 +183,10 @@ function DashboardPage() {
|
||||
<Card padding="md" className="flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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">
|
||||
Délai moyen entre émission et paiement.
|
||||
</p>
|
||||
|
||||
@ -8,6 +8,8 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
|
||||
import { GLOSSARY } from "@/lib/glossary";
|
||||
import { EncaisseChart } from "@/components/charts/EncaisseChart";
|
||||
import { DsoTrendChart } from "@/components/charts/DsoTrendChart";
|
||||
import {
|
||||
@ -91,7 +93,12 @@ function InsightsPage() {
|
||||
value={formatEuros(totalEncaisse)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
<Card padding="md">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
<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 (
|
||||
<Card padding="md">
|
||||
<p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-ink-3">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user