add chart details
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy API / build-and-deploy (push) Successful in 1m7s

This commit is contained in:
ordinarthur 2026-05-07 11:42:36 +02:00
parent 1633fb9bf0
commit 89c9a732d6
8 changed files with 221 additions and 72 deletions

View File

@ -156,14 +156,27 @@ export async function topLatePayers(
export type RangeMonths = 3 | 6 | 12 export type RangeMonths = 3 | 6 | 12
export type PaidByMonthPoint = { /**
/** Premier jour du mois en ISO date "YYYY-MM-01". */ * Granularité d'agrégation des séries temporelles.
month: string * - `month` : 1 bucket = 1 mois (vue 6m / 12m, lecture macro)
/** Total encaissé sur le mois (centimes). */ * - `week` : 1 bucket = 1 semaine ISO (lundi dimanche), pour la vue
* 3 mois l'agrégation mensuelle masque trop la dynamique
* (3 points seulement, on perd la lecture).
*/
export type Granularity = 'month' | 'week'
export type PaidSeriesPoint = {
/**
* Premier jour du bucket en ISO date "YYYY-MM-DD".
* - `month`: premier du mois (toujours -01)
* - `week` : lundi de la semaine
*/
bucket: string
/** Total encaissé sur le bucket (centimes). */
encaisseCents: number encaisseCents: number
/** Nombre de factures payées sur le mois. */ /** Nombre de factures payées sur le bucket. */
paidCount: number paidCount: number
/** DSO moyen sur le mois (jours, 0 si aucun paiement). */ /** DSO moyen sur le bucket (jours, 0 si aucun paiement). */
dsoDays: number dsoDays: number
} }
@ -175,10 +188,18 @@ export type PipelineSlice = {
export type DashboardTimeseries = { export type DashboardTimeseries = {
range: RangeMonths range: RangeMonths
paidByMonth: PaidByMonthPoint[] granularity: Granularity
/** Garde le nom pour ne pas casser le contrat mais les buckets peuvent
* être hebdo (cf. `granularity`). */
paidByMonth: PaidSeriesPoint[]
pipelineByStatus: PipelineSlice[] pipelineByStatus: PipelineSlice[]
} }
/** Choix de granularité par défaut pour un range donné. */
function pickGranularity(range: RangeMonths): Granularity {
return range === 3 ? 'week' : 'month'
}
/** /**
* Calcule les séries temporelles pour le dashboard / insights. * Calcule les séries temporelles pour le dashboard / insights.
* *
@ -192,7 +213,8 @@ export async function computeTimeseries(
organizationId: string, organizationId: string,
range: RangeMonths = 6 range: RangeMonths = 6
): Promise<DashboardTimeseries> { ): Promise<DashboardTimeseries> {
const paidByMonth = await fetchPaidByMonth({ organizationId, range }) const granularity = pickGranularity(range)
const paidByMonth = await fetchPaidSeries({ organizationId, range, granularity })
const pipelineRows = (await db const pipelineRows = (await db
.from('invoices') .from('invoices')
@ -219,7 +241,7 @@ export async function computeTimeseries(
return { status, count: r?.count ?? 0, amountCents: r?.amount_cents ?? 0 } return { status, count: r?.count ?? 0, amountCents: r?.amount_cents ?? 0 }
}) })
return { range, paidByMonth, pipelineByStatus } return { range, granularity, paidByMonth, pipelineByStatus }
} }
/** Variante par client — on filtre paidByMonth sur un client_id. */ /** Variante par client — on filtre paidByMonth sur un client_id. */
@ -227,27 +249,61 @@ export async function computeClientTimeseries(
organizationId: string, organizationId: string,
clientId: string, clientId: string,
range: RangeMonths = 6 range: RangeMonths = 6
): Promise<{ range: RangeMonths; paidByMonth: PaidByMonthPoint[] }> { ): Promise<{ range: RangeMonths; granularity: Granularity; paidByMonth: PaidSeriesPoint[] }> {
const paidByMonth = await fetchPaidByMonth({ organizationId, clientId, range }) const granularity = pickGranularity(range)
return { range, paidByMonth } const paidByMonth = await fetchPaidSeries({
organizationId,
clientId,
range,
granularity,
})
return { range, granularity, paidByMonth }
} }
/** /**
* Helper interne DRY entre computeTimeseries et computeClientTimeseries. * Helper interne DRY entre computeTimeseries et computeClientTimeseries.
* Renvoie N buckets mensuels (range derniers mois inclus) avec encaisse/count/dso. *
* Renvoie N buckets ordonnés (mensuels ou hebdomadaires selon `granularity`)
* couvrant les derniers `range` mois. Chaque bucket porte encaisse/count/dso.
*
* - `month` : `range` buckets mensuels (1/mois), label = 1er du mois
* - `week` : ~`range * 4.5` buckets hebdo (1/semaine), label = lundi de la
* semaine. Postgres `date_trunc('week')` aligne sur lundi (ISO),
* Luxon `startOf('week')` aussi cohérence garantie.
*/ */
async function fetchPaidByMonth(params: { async function fetchPaidSeries(params: {
organizationId: string organizationId: string
clientId?: string clientId?: string
range: RangeMonths range: RangeMonths
}): Promise<PaidByMonthPoint[]> { granularity: Granularity
}): Promise<PaidSeriesPoint[]> {
const now = await clock.now(params.organizationId) const now = await clock.now(params.organizationId)
const firstBucket = now.minus({ months: params.range - 1 }).startOf('month')
const buckets = new Map<string, PaidByMonthPoint>() // Garde-fou : `truncUnit` est interpolé dans du SQL brut, on whitelist.
for (let i = 0; i < params.range; i++) { const truncUnit: 'month' | 'week' =
const m = firstBucket.plus({ months: i }).toFormat('yyyy-LL-01') params.granularity === 'week' ? 'week' : 'month'
buckets.set(m, { month: m, encaisseCents: 0, paidCount: 0, dsoDays: 0 })
let firstBucket: DateTime
let bucketCount: number
let stepUnit: 'months' | 'weeks'
if (truncUnit === 'week') {
// ~range * 4.33 semaines couvrent la fenêtre, on prend ceil pour ne pas
// tronquer le premier mois.
bucketCount = Math.ceil(params.range * 4.34)
firstBucket = now.startOf('week').minus({ weeks: bucketCount - 1 })
stepUnit = 'weeks'
} else {
bucketCount = params.range
firstBucket = now.minus({ months: bucketCount - 1 }).startOf('month')
stepUnit = 'months'
}
const buckets = new Map<string, PaidSeriesPoint>()
for (let i = 0; i < bucketCount; i++) {
const inc = stepUnit === 'weeks' ? { weeks: i } : { months: i }
const b = firstBucket.plus(inc).toFormat('yyyy-LL-dd')
buckets.set(b, { bucket: b, encaisseCents: 0, paidCount: 0, dsoDays: 0 })
} }
const query = db const query = db
@ -260,25 +316,25 @@ async function fetchPaidByMonth(params: {
const rows = (await query const rows = (await query
.select( .select(
db.raw(`to_char(date_trunc('month', paid_at), 'YYYY-MM-01') as month`), db.raw(`to_char(date_trunc('${truncUnit}', paid_at), 'YYYY-MM-DD') as bucket`),
db.raw(`coalesce(sum(amount_ttc_cents), 0)::int as encaisse_cents`), db.raw(`coalesce(sum(amount_ttc_cents), 0)::int as encaisse_cents`),
db.raw(`count(*)::int as paid_count`), db.raw(`count(*)::int as paid_count`),
db.raw( db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400)::int, 0) as dso_days` `coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400)::int, 0) as dso_days`
) )
) )
.groupByRaw(`date_trunc('month', paid_at)`) .groupByRaw(`date_trunc('${truncUnit}', paid_at)`)
.orderByRaw(`date_trunc('month', paid_at)`)) as Array<{ .orderByRaw(`date_trunc('${truncUnit}', paid_at)`)) as Array<{
month: string bucket: string
encaisse_cents: number encaisse_cents: number
paid_count: number paid_count: number
dso_days: number dso_days: number
}> }>
for (const r of rows) { for (const r of rows) {
if (!buckets.has(r.month)) continue if (!buckets.has(r.bucket)) continue
buckets.set(r.month, { buckets.set(r.bucket, {
month: r.month, bucket: r.bucket,
encaisseCents: r.encaisse_cents, encaisseCents: r.encaisse_cents,
paidCount: r.paid_count, paidCount: r.paid_count,
dsoDays: r.dso_days, dsoDays: r.dso_days,

View File

@ -13,22 +13,28 @@ import { formatEuros } from "@/lib/format";
import { import {
AXIS_TICK_STYLE, AXIS_TICK_STYLE,
chartColors, chartColors,
formatMonthLong, formatBucketLong,
formatMonthShort, formatBucketShort,
type SeriesGranularity,
} from "./theme"; } from "./theme";
import { ChartTooltip } from "./ChartTooltip"; import { ChartTooltip } from "./ChartTooltip";
/** /**
* Mini bar chart "encaissé par mois" pour la fiche client. * Mini bar chart "encaissé par bucket" pour la fiche client.
* Format compact (height 140) + barres rubis-glow / rubis selon que * Format compact (height 140) + barres rubis. Pas d'axe Y, montants dans
* le mois a eu un paiement ou non. Pas d'axe Y, montants dans le tooltip. * le tooltip. Granularité (mensuelle / hebdo) pilotée par prop.
*/ */
type ClientPaidChartProps = { type ClientPaidChartProps = {
data: Array<{ month: string; encaisseCents: number; paidCount: number }>; data: Array<{ bucket: string; encaisseCents: number; paidCount: number }>;
granularity?: SeriesGranularity;
height?: number; height?: number;
}; };
export function ClientPaidChart({ data, height = 140 }: ClientPaidChartProps) { export function ClientPaidChart({
data,
granularity = "month",
height = 140,
}: ClientPaidChartProps) {
const c = chartColors(); const c = chartColors();
// Si aucun paiement sur la période, on affiche un message — un chart vide // Si aucun paiement sur la période, on affiche un message — un chart vide
@ -44,28 +50,30 @@ export function ClientPaidChart({ data, height = 140 }: ClientPaidChartProps) {
className="flex items-center justify-center text-[12px] italic text-ink-3" className="flex items-center justify-center text-[12px] italic text-ink-3"
style={{ height }} style={{ height }}
> >
Aucun paiement reçu de ce client sur les 6 derniers mois. Aucun paiement reçu de ce client sur la période.
</div> </div>
); );
} }
const xInterval = granularity === "week" ? 1 : 0;
return ( return (
<ResponsiveContainer width="100%" height={height}> <ResponsiveContainer width="100%" height={height}>
<BarChart data={data} margin={{ top: 4, right: 4, left: 4, bottom: 0 }}> <BarChart data={data} margin={{ top: 4, right: 4, left: 4, bottom: 0 }}>
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} /> <CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
<XAxis <XAxis
dataKey="month" dataKey="bucket"
tickFormatter={formatMonthShort} tickFormatter={(v) => formatBucketShort(v, granularity)}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={AXIS_TICK_STYLE} tick={AXIS_TICK_STYLE}
interval={xInterval}
dy={4} dy={4}
/> />
<YAxis hide domain={[0, "dataMax + 100"]} /> <YAxis hide domain={[0, "dataMax + 100"]} />
<Tooltip <Tooltip
content={ content={
<ChartTooltip <ChartTooltip
formatLabel={formatMonthLong} formatLabel={(v) => formatBucketLong(v, granularity)}
formatValue={(v, key) => formatValue={(v, key) =>
key === "encaisseCents" key === "encaisseCents"
? formatEuros(v) ? formatEuros(v)

View File

@ -12,18 +12,21 @@ import {
import { import {
AXIS_TICK_STYLE, AXIS_TICK_STYLE,
chartColors, chartColors,
formatMonthLong, formatBucketLong,
formatMonthShort, formatBucketShort,
type SeriesGranularity,
} from "./theme"; } from "./theme";
import { ChartTooltip } from "./ChartTooltip"; import { ChartTooltip } from "./ChartTooltip";
/** /**
* DSO mensuel line chart en ink (pas rubis pour distinguer du chart * DSO line chart en ink (pas rubis pour distinguer du chart encaissé).
* encaissé). Une ReferenceLine pointillée à 30j sert de repère visuel * Granularité (mensuelle / hebdo) pilotée par la prop `granularity`.
* (norme LME pour le délai de paiement standard B2B). * Une ReferenceLine pointillée à 30j sert de repère visuel (norme LME pour
* le délai de paiement standard B2B).
*/ */
type DsoTrendChartProps = { type DsoTrendChartProps = {
data: Array<{ month: string; dsoDays: number; paidCount: number }>; data: Array<{ bucket: string; dsoDays: number; paidCount: number }>;
granularity?: SeriesGranularity;
height?: number; height?: number;
/** Référence à afficher en pointillé (30j = LME standard). */ /** Référence à afficher en pointillé (30j = LME standard). */
reference?: number; reference?: number;
@ -31,30 +34,32 @@ type DsoTrendChartProps = {
export function DsoTrendChart({ export function DsoTrendChart({
data, data,
granularity = "month",
height = 200, height = 200,
reference = 30, reference = 30,
}: DsoTrendChartProps) { }: DsoTrendChartProps) {
const c = chartColors(); const c = chartColors();
// On ne plotte pas les mois sans paiement — sinon la ligne tombe à 0 // On ne plotte pas les buckets sans paiement — sinon la ligne tombe à 0
// et raconte une histoire fausse. On filtre ces points et on les // et raconte une histoire fausse. On met null pour avoir un trou visuel
// gardera comme "trous" visuels (Recharts gère via connectNulls=false // (Recharts gère via connectNulls=false).
// si on met null sur la valeur).
const enriched = data.map((d) => ({ const enriched = data.map((d) => ({
...d, ...d,
dsoDays: d.paidCount > 0 ? d.dsoDays : null, dsoDays: d.paidCount > 0 ? d.dsoDays : null,
})); }));
const xInterval = granularity === "week" ? 1 : 0;
return ( return (
<ResponsiveContainer width="100%" height={height}> <ResponsiveContainer width="100%" height={height}>
<LineChart data={enriched} margin={{ top: 10, right: 8, left: 8, bottom: 0 }}> <LineChart data={enriched} margin={{ top: 10, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} /> <CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
<XAxis <XAxis
dataKey="month" dataKey="bucket"
tickFormatter={formatMonthShort} tickFormatter={(v) => formatBucketShort(v, granularity)}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={AXIS_TICK_STYLE} tick={AXIS_TICK_STYLE}
interval={xInterval}
dy={6} dy={6}
/> />
<YAxis <YAxis
@ -81,7 +86,7 @@ export function DsoTrendChart({
<Tooltip <Tooltip
content={ content={
<ChartTooltip <ChartTooltip
formatLabel={formatMonthLong} formatLabel={(v) => formatBucketLong(v, granularity)}
formatValue={(v, key) => formatValue={(v, key) =>
key === "dsoDays" ? `${Math.round(v)} jours` : String(v) key === "dsoDays" ? `${Math.round(v)} jours` : String(v)
} }

View File

@ -12,26 +12,37 @@ import { formatEuros } from "@/lib/format";
import { import {
AXIS_TICK_STYLE, AXIS_TICK_STYLE,
chartColors, chartColors,
formatMonthLong, formatBucketLong,
formatMonthShort, formatBucketShort,
type SeriesGranularity,
} from "./theme"; } from "./theme";
import { ChartTooltip } from "./ChartTooltip"; import { ChartTooltip } from "./ChartTooltip";
/** /**
* Encaissé mensuel area chart avec dégradé rubis. * Encaissé area chart avec dégradé rubis. La granularité de l'axe X dépend
* X = mois (avr, mai, juin), Y = euros encaissés. * de la prop `granularity` :
* - `month` : ticks "mai", tooltip "Mai 2026"
* - `week` : ticks "12 mai", tooltip "Semaine du 12 mai 2026"
*
* Pas d'axe Y visible : les valeurs sont dans le tooltip pour rester * Pas d'axe Y visible : les valeurs sont dans le tooltip pour rester
* visuellement épuré (le client veut "j'ai gagné combien ce mois", pas * visuellement épuré.
* une grille graduée).
*/ */
type EncaisseChartProps = { type EncaisseChartProps = {
data: Array<{ month: string; encaisseCents: number }>; data: Array<{ bucket: string; encaisseCents: number }>;
granularity?: SeriesGranularity;
/** Hauteur fixe en px (défaut 220). */ /** Hauteur fixe en px (défaut 220). */
height?: number; height?: number;
}; };
export function EncaisseChart({ data, height = 220 }: EncaisseChartProps) { export function EncaisseChart({
data,
granularity = "month",
height = 220,
}: EncaisseChartProps) {
const c = chartColors(); const c = chartColors();
// En vue hebdo on a ~13 ticks, le label "12 mai" prend de la place — on en
// saute pour aérer (1 sur 2 → 7 visibles).
const xInterval = granularity === "week" ? 1 : 0;
return ( return (
<ResponsiveContainer width="100%" height={height}> <ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 10, right: 8, left: 8, bottom: 0 }}> <AreaChart data={data} margin={{ top: 10, right: 8, left: 8, bottom: 0 }}>
@ -43,18 +54,19 @@ export function EncaisseChart({ data, height = 220 }: EncaisseChartProps) {
</defs> </defs>
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} /> <CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
<XAxis <XAxis
dataKey="month" dataKey="bucket"
tickFormatter={formatMonthShort} tickFormatter={(v) => formatBucketShort(v, granularity)}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={AXIS_TICK_STYLE} tick={AXIS_TICK_STYLE}
interval={xInterval}
dy={6} dy={6}
/> />
<YAxis hide domain={[0, "dataMax + 100"]} /> <YAxis hide domain={[0, "dataMax + 100"]} />
<Tooltip <Tooltip
content={ content={
<ChartTooltip <ChartTooltip
formatLabel={formatMonthLong} formatLabel={(v) => formatBucketLong(v, granularity)}
formatValue={(v) => formatEuros(v)} formatValue={(v) => formatEuros(v)}
seriesLabel={{ encaisseCents: "Encaissé" }} seriesLabel={{ encaisseCents: "Encaissé" }}
/> />

View File

@ -60,6 +60,9 @@ export const AXIS_TICK_STYLE: CSSProperties = {
fill: "var(--color-ink-3)", fill: "var(--color-ink-3)",
}; };
/** Granularité d'agrégation d'une série temporelle (mensuelle ou hebdo). */
export type SeriesGranularity = "month" | "week";
/** Format mois "2026-04-01" → "avr" pour les ticks X compacts. */ /** Format mois "2026-04-01" → "avr" pour les ticks X compacts. */
const MONTH_FORMAT = new Intl.DateTimeFormat("fr-FR", { month: "short" }); const MONTH_FORMAT = new Intl.DateTimeFormat("fr-FR", { month: "short" });
export function formatMonthShort(monthIso: string): string { export function formatMonthShort(monthIso: string): string {
@ -79,3 +82,41 @@ export function formatMonthLong(monthIso: string): string {
if (Number.isNaN(d.getTime())) return monthIso; if (Number.isNaN(d.getTime())) return monthIso;
return MONTH_LONG.format(d); return MONTH_LONG.format(d);
} }
/** Format jour court "12 mai" pour les ticks X de la vue hebdo. */
const DAY_SHORT = new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "short",
});
export function formatDayShort(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return DAY_SHORT.format(d).replace(".", "");
}
/** Format "Semaine du 12 mai 2026" pour les tooltips hebdo. */
const DAY_LONG = new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
});
export function formatWeekLong(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return `Semaine du ${DAY_LONG.format(d)}`;
}
/** Sélecteur unifié — formate un bucket selon la granularité. */
export function formatBucketShort(
iso: string,
granularity: SeriesGranularity,
): string {
return granularity === "week" ? formatDayShort(iso) : formatMonthShort(iso);
}
export function formatBucketLong(
iso: string,
granularity: SeriesGranularity,
): string {
return granularity === "week" ? formatWeekLong(iso) : formatMonthLong(iso);
}

View File

@ -34,8 +34,9 @@ import type { ClientWithStats } from "@/components/clients/ClientTable";
type ClientTimeseries = { type ClientTimeseries = {
range: 3 | 6 | 12; range: 3 | 6 | 12;
granularity: "month" | "week";
paidByMonth: Array<{ paidByMonth: Array<{
month: string; bucket: string;
encaisseCents: number; encaisseCents: number;
paidCount: number; paidCount: number;
dsoDays: number; dsoDays: number;
@ -217,7 +218,10 @@ function ClientDetailPage() {
</p> </p>
</div> </div>
</div> </div>
<ClientPaidChart data={timeseries?.paidByMonth ?? []} /> <ClientPaidChart
data={timeseries?.paidByMonth ?? []}
granularity={timeseries?.granularity}
/>
</Card> </Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.5fr_1fr]"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.5fr_1fr]">

View File

@ -30,8 +30,9 @@ import {
type Timeseries = { type Timeseries = {
range: 3 | 6 | 12; range: 3 | 6 | 12;
granularity: "month" | "week";
paidByMonth: Array<{ paidByMonth: Array<{
month: string; bucket: string;
encaisseCents: number; encaisseCents: number;
paidCount: number; paidCount: number;
dsoDays: number; dsoDays: number;
@ -177,7 +178,10 @@ function DashboardPage() {
Détails Détails
</Link> </Link>
</div> </div>
<EncaisseChart data={timeseries?.paidByMonth ?? []} /> <EncaisseChart
data={timeseries?.paidByMonth ?? []}
granularity={timeseries?.granularity}
/>
</Card> </Card>
<Card padding="md" className="flex flex-col"> <Card padding="md" className="flex flex-col">
@ -198,7 +202,10 @@ function DashboardPage() {
Détails Détails
</Link> </Link>
</div> </div>
<DsoTrendChart data={timeseries?.paidByMonth ?? []} /> <DsoTrendChart
data={timeseries?.paidByMonth ?? []}
granularity={timeseries?.granularity}
/>
</Card> </Card>
</section> </section>

View File

@ -18,11 +18,13 @@ import {
} from "@/components/charts/PipelineChart"; } from "@/components/charts/PipelineChart";
type Range = 3 | 6 | 12; type Range = 3 | 6 | 12;
type Granularity = "month" | "week";
type Timeseries = { type Timeseries = {
range: Range; range: Range;
granularity: Granularity;
paidByMonth: Array<{ paidByMonth: Array<{
month: string; bucket: string;
encaisseCents: number; encaisseCents: number;
paidCount: number; paidCount: number;
dsoDays: number; dsoDays: number;
@ -104,12 +106,22 @@ function InsightsPage() {
{/* Encaissé — pleine largeur, chart le plus important */} {/* Encaissé — pleine largeur, chart le plus important */}
<Card padding="md"> <Card padding="md">
<div className="mb-4"> <div className="mb-4">
<Eyebrow tone="ink">Encaissement mensuel</Eyebrow> <Eyebrow tone="ink">
{ts?.granularity === "week"
? "Encaissement hebdomadaire"
: "Encaissement mensuel"}
</Eyebrow>
<p className="mt-1 text-[13px] text-ink-3"> <p className="mt-1 text-[13px] text-ink-3">
Total facturé encaissé chaque mois sur la période sélectionnée. {ts?.granularity === "week"
? "Total encaissé par semaine — la vue 3 mois zoome sur la dynamique récente."
: "Total facturé encaissé chaque mois sur la période sélectionnée."}
</p> </p>
</div> </div>
<EncaisseChart data={ts?.paidByMonth ?? []} height={300} /> <EncaisseChart
data={ts?.paidByMonth ?? []}
granularity={ts?.granularity}
height={300}
/>
</Card> </Card>
{/* DSO + Pipeline côte à côte */} {/* DSO + Pipeline côte à côte */}
@ -126,7 +138,11 @@ function InsightsPage() {
<GlossaryTerm definition={GLOSSARY.lme}>LME</GlossaryTerm> pour le B2B. <GlossaryTerm definition={GLOSSARY.lme}>LME</GlossaryTerm> pour le B2B.
</p> </p>
</div> </div>
<DsoTrendChart data={ts?.paidByMonth ?? []} height={260} /> <DsoTrendChart
data={ts?.paidByMonth ?? []}
granularity={ts?.granularity}
height={260}
/>
</Card> </Card>
<Card padding="md"> <Card padding="md">