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.
|
* - 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. */
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user