add chart details
This commit is contained in:
parent
1633fb9bf0
commit
89c9a732d6
@ -156,14 +156,27 @@ export async function topLatePayers(
|
||||
|
||||
export type RangeMonths = 3 | 6 | 12
|
||||
|
||||
export type PaidByMonthPoint = {
|
||||
/** Premier jour du mois en ISO date "YYYY-MM-01". */
|
||||
month: string
|
||||
/** Total encaissé sur le mois (centimes). */
|
||||
/**
|
||||
* Granularité d'agrégation des séries temporelles.
|
||||
* - `month` : 1 bucket = 1 mois (vue 6m / 12m, lecture macro)
|
||||
* - `week` : 1 bucket = 1 semaine ISO (lundi → dimanche), pour la vue
|
||||
* 3 mois où 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
|
||||
/** Nombre de factures payées sur le mois. */
|
||||
/** Nombre de factures payées sur le bucket. */
|
||||
paidCount: number
|
||||
/** DSO moyen sur le mois (jours, 0 si aucun paiement). */
|
||||
/** DSO moyen sur le bucket (jours, 0 si aucun paiement). */
|
||||
dsoDays: number
|
||||
}
|
||||
|
||||
@ -175,10 +188,18 @@ export type PipelineSlice = {
|
||||
|
||||
export type DashboardTimeseries = {
|
||||
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[]
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
@ -192,7 +213,8 @@ export async function computeTimeseries(
|
||||
organizationId: string,
|
||||
range: RangeMonths = 6
|
||||
): Promise<DashboardTimeseries> {
|
||||
const paidByMonth = await fetchPaidByMonth({ organizationId, range })
|
||||
const granularity = pickGranularity(range)
|
||||
const paidByMonth = await fetchPaidSeries({ organizationId, range, granularity })
|
||||
|
||||
const pipelineRows = (await db
|
||||
.from('invoices')
|
||||
@ -219,7 +241,7 @@ export async function computeTimeseries(
|
||||
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. */
|
||||
@ -227,27 +249,61 @@ export async function computeClientTimeseries(
|
||||
organizationId: string,
|
||||
clientId: string,
|
||||
range: RangeMonths = 6
|
||||
): Promise<{ range: RangeMonths; paidByMonth: PaidByMonthPoint[] }> {
|
||||
const paidByMonth = await fetchPaidByMonth({ organizationId, clientId, range })
|
||||
return { range, paidByMonth }
|
||||
): Promise<{ range: RangeMonths; granularity: Granularity; paidByMonth: PaidSeriesPoint[] }> {
|
||||
const granularity = pickGranularity(range)
|
||||
const paidByMonth = await fetchPaidSeries({
|
||||
organizationId,
|
||||
clientId,
|
||||
range,
|
||||
granularity,
|
||||
})
|
||||
return { range, granularity, paidByMonth }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
clientId?: string
|
||||
range: RangeMonths
|
||||
}): Promise<PaidByMonthPoint[]> {
|
||||
granularity: Granularity
|
||||
}): Promise<PaidSeriesPoint[]> {
|
||||
const now = await clock.now(params.organizationId)
|
||||
const firstBucket = now.minus({ months: params.range - 1 }).startOf('month')
|
||||
|
||||
const buckets = new Map<string, PaidByMonthPoint>()
|
||||
for (let i = 0; i < params.range; i++) {
|
||||
const m = firstBucket.plus({ months: i }).toFormat('yyyy-LL-01')
|
||||
buckets.set(m, { month: m, encaisseCents: 0, paidCount: 0, dsoDays: 0 })
|
||||
// Garde-fou : `truncUnit` est interpolé dans du SQL brut, on whitelist.
|
||||
const truncUnit: 'month' | 'week' =
|
||||
params.granularity === 'week' ? 'week' : 'month'
|
||||
|
||||
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
|
||||
@ -260,25 +316,25 @@ async function fetchPaidByMonth(params: {
|
||||
|
||||
const rows = (await query
|
||||
.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(`count(*)::int as paid_count`),
|
||||
db.raw(
|
||||
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400)::int, 0) as dso_days`
|
||||
)
|
||||
)
|
||||
.groupByRaw(`date_trunc('month', paid_at)`)
|
||||
.orderByRaw(`date_trunc('month', paid_at)`)) as Array<{
|
||||
month: string
|
||||
.groupByRaw(`date_trunc('${truncUnit}', paid_at)`)
|
||||
.orderByRaw(`date_trunc('${truncUnit}', paid_at)`)) as Array<{
|
||||
bucket: string
|
||||
encaisse_cents: number
|
||||
paid_count: number
|
||||
dso_days: number
|
||||
}>
|
||||
|
||||
for (const r of rows) {
|
||||
if (!buckets.has(r.month)) continue
|
||||
buckets.set(r.month, {
|
||||
month: r.month,
|
||||
if (!buckets.has(r.bucket)) continue
|
||||
buckets.set(r.bucket, {
|
||||
bucket: r.bucket,
|
||||
encaisseCents: r.encaisse_cents,
|
||||
paidCount: r.paid_count,
|
||||
dsoDays: r.dso_days,
|
||||
|
||||
@ -13,22 +13,28 @@ import { formatEuros } from "@/lib/format";
|
||||
import {
|
||||
AXIS_TICK_STYLE,
|
||||
chartColors,
|
||||
formatMonthLong,
|
||||
formatMonthShort,
|
||||
formatBucketLong,
|
||||
formatBucketShort,
|
||||
type SeriesGranularity,
|
||||
} from "./theme";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
/**
|
||||
* Mini bar chart "encaissé par mois" pour la fiche client.
|
||||
* Format compact (height 140) + barres rubis-glow / rubis selon que
|
||||
* le mois a eu un paiement ou non. Pas d'axe Y, montants dans le tooltip.
|
||||
* Mini bar chart "encaissé par bucket" pour la fiche client.
|
||||
* Format compact (height 140) + barres rubis. Pas d'axe Y, montants dans
|
||||
* le tooltip. Granularité (mensuelle / hebdo) pilotée par prop.
|
||||
*/
|
||||
type ClientPaidChartProps = {
|
||||
data: Array<{ month: string; encaisseCents: number; paidCount: number }>;
|
||||
data: Array<{ bucket: string; encaisseCents: number; paidCount: number }>;
|
||||
granularity?: SeriesGranularity;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export function ClientPaidChart({ data, height = 140 }: ClientPaidChartProps) {
|
||||
export function ClientPaidChart({
|
||||
data,
|
||||
granularity = "month",
|
||||
height = 140,
|
||||
}: ClientPaidChartProps) {
|
||||
const c = chartColors();
|
||||
|
||||
// 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"
|
||||
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>
|
||||
);
|
||||
}
|
||||
const xInterval = granularity === "week" ? 1 : 0;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 4, left: 4, bottom: 0 }}>
|
||||
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickFormatter={formatMonthShort}
|
||||
dataKey="bucket"
|
||||
tickFormatter={(v) => formatBucketShort(v, granularity)}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={AXIS_TICK_STYLE}
|
||||
interval={xInterval}
|
||||
dy={4}
|
||||
/>
|
||||
<YAxis hide domain={[0, "dataMax + 100"]} />
|
||||
<Tooltip
|
||||
content={
|
||||
<ChartTooltip
|
||||
formatLabel={formatMonthLong}
|
||||
formatLabel={(v) => formatBucketLong(v, granularity)}
|
||||
formatValue={(v, key) =>
|
||||
key === "encaisseCents"
|
||||
? formatEuros(v)
|
||||
|
||||
@ -12,18 +12,21 @@ import {
|
||||
import {
|
||||
AXIS_TICK_STYLE,
|
||||
chartColors,
|
||||
formatMonthLong,
|
||||
formatMonthShort,
|
||||
formatBucketLong,
|
||||
formatBucketShort,
|
||||
type SeriesGranularity,
|
||||
} from "./theme";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
/**
|
||||
* DSO mensuel — line chart en ink (pas rubis pour distinguer du chart
|
||||
* encaissé). Une ReferenceLine pointillée à 30j sert de repère visuel
|
||||
* (norme LME pour le délai de paiement standard B2B).
|
||||
* DSO — line chart en ink (pas rubis pour distinguer du chart encaissé).
|
||||
* Granularité (mensuelle / hebdo) pilotée par la prop `granularity`.
|
||||
* Une ReferenceLine pointillée à 30j sert de repère visuel (norme LME pour
|
||||
* le délai de paiement standard B2B).
|
||||
*/
|
||||
type DsoTrendChartProps = {
|
||||
data: Array<{ month: string; dsoDays: number; paidCount: number }>;
|
||||
data: Array<{ bucket: string; dsoDays: number; paidCount: number }>;
|
||||
granularity?: SeriesGranularity;
|
||||
height?: number;
|
||||
/** Référence à afficher en pointillé (30j = LME standard). */
|
||||
reference?: number;
|
||||
@ -31,30 +34,32 @@ type DsoTrendChartProps = {
|
||||
|
||||
export function DsoTrendChart({
|
||||
data,
|
||||
granularity = "month",
|
||||
height = 200,
|
||||
reference = 30,
|
||||
}: DsoTrendChartProps) {
|
||||
const c = chartColors();
|
||||
|
||||
// On ne plotte pas les mois sans paiement — sinon la ligne tombe à 0
|
||||
// et raconte une histoire fausse. On filtre ces points et on les
|
||||
// gardera comme "trous" visuels (Recharts gère via connectNulls=false
|
||||
// si on met null sur la valeur).
|
||||
// On ne plotte pas les buckets sans paiement — sinon la ligne tombe à 0
|
||||
// et raconte une histoire fausse. On met null pour avoir un trou visuel
|
||||
// (Recharts gère via connectNulls=false).
|
||||
const enriched = data.map((d) => ({
|
||||
...d,
|
||||
dsoDays: d.paidCount > 0 ? d.dsoDays : null,
|
||||
}));
|
||||
const xInterval = granularity === "week" ? 1 : 0;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={enriched} margin={{ top: 10, right: 8, left: 8, bottom: 0 }}>
|
||||
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickFormatter={formatMonthShort}
|
||||
dataKey="bucket"
|
||||
tickFormatter={(v) => formatBucketShort(v, granularity)}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={AXIS_TICK_STYLE}
|
||||
interval={xInterval}
|
||||
dy={6}
|
||||
/>
|
||||
<YAxis
|
||||
@ -81,7 +86,7 @@ export function DsoTrendChart({
|
||||
<Tooltip
|
||||
content={
|
||||
<ChartTooltip
|
||||
formatLabel={formatMonthLong}
|
||||
formatLabel={(v) => formatBucketLong(v, granularity)}
|
||||
formatValue={(v, key) =>
|
||||
key === "dsoDays" ? `${Math.round(v)} jours` : String(v)
|
||||
}
|
||||
|
||||
@ -12,26 +12,37 @@ import { formatEuros } from "@/lib/format";
|
||||
import {
|
||||
AXIS_TICK_STYLE,
|
||||
chartColors,
|
||||
formatMonthLong,
|
||||
formatMonthShort,
|
||||
formatBucketLong,
|
||||
formatBucketShort,
|
||||
type SeriesGranularity,
|
||||
} from "./theme";
|
||||
import { ChartTooltip } from "./ChartTooltip";
|
||||
|
||||
/**
|
||||
* Encaissé mensuel — area chart avec dégradé rubis.
|
||||
* X = mois (avr, mai, juin…), Y = euros encaissés.
|
||||
* Encaissé — area chart avec dégradé rubis. La granularité de l'axe X dépend
|
||||
* 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
|
||||
* visuellement épuré (le client veut "j'ai gagné combien ce mois", pas
|
||||
* une grille graduée).
|
||||
* visuellement épuré.
|
||||
*/
|
||||
type EncaisseChartProps = {
|
||||
data: Array<{ month: string; encaisseCents: number }>;
|
||||
data: Array<{ bucket: string; encaisseCents: number }>;
|
||||
granularity?: SeriesGranularity;
|
||||
/** Hauteur fixe en px (défaut 220). */
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export function EncaisseChart({ data, height = 220 }: EncaisseChartProps) {
|
||||
export function EncaisseChart({
|
||||
data,
|
||||
granularity = "month",
|
||||
height = 220,
|
||||
}: EncaisseChartProps) {
|
||||
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 (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data} margin={{ top: 10, right: 8, left: 8, bottom: 0 }}>
|
||||
@ -43,18 +54,19 @@ export function EncaisseChart({ data, height = 220 }: EncaisseChartProps) {
|
||||
</defs>
|
||||
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickFormatter={formatMonthShort}
|
||||
dataKey="bucket"
|
||||
tickFormatter={(v) => formatBucketShort(v, granularity)}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={AXIS_TICK_STYLE}
|
||||
interval={xInterval}
|
||||
dy={6}
|
||||
/>
|
||||
<YAxis hide domain={[0, "dataMax + 100"]} />
|
||||
<Tooltip
|
||||
content={
|
||||
<ChartTooltip
|
||||
formatLabel={formatMonthLong}
|
||||
formatLabel={(v) => formatBucketLong(v, granularity)}
|
||||
formatValue={(v) => formatEuros(v)}
|
||||
seriesLabel={{ encaisseCents: "Encaissé" }}
|
||||
/>
|
||||
|
||||
@ -60,6 +60,9 @@ export const AXIS_TICK_STYLE: CSSProperties = {
|
||||
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. */
|
||||
const MONTH_FORMAT = new Intl.DateTimeFormat("fr-FR", { month: "short" });
|
||||
export function formatMonthShort(monthIso: string): string {
|
||||
@ -79,3 +82,41 @@ export function formatMonthLong(monthIso: string): string {
|
||||
if (Number.isNaN(d.getTime())) return monthIso;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -34,8 +34,9 @@ import type { ClientWithStats } from "@/components/clients/ClientTable";
|
||||
|
||||
type ClientTimeseries = {
|
||||
range: 3 | 6 | 12;
|
||||
granularity: "month" | "week";
|
||||
paidByMonth: Array<{
|
||||
month: string;
|
||||
bucket: string;
|
||||
encaisseCents: number;
|
||||
paidCount: number;
|
||||
dsoDays: number;
|
||||
@ -217,7 +218,10 @@ function ClientDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ClientPaidChart data={timeseries?.paidByMonth ?? []} />
|
||||
<ClientPaidChart
|
||||
data={timeseries?.paidByMonth ?? []}
|
||||
granularity={timeseries?.granularity}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.5fr_1fr]">
|
||||
|
||||
@ -30,8 +30,9 @@ import {
|
||||
|
||||
type Timeseries = {
|
||||
range: 3 | 6 | 12;
|
||||
granularity: "month" | "week";
|
||||
paidByMonth: Array<{
|
||||
month: string;
|
||||
bucket: string;
|
||||
encaisseCents: number;
|
||||
paidCount: number;
|
||||
dsoDays: number;
|
||||
@ -177,7 +178,10 @@ function DashboardPage() {
|
||||
Détails →
|
||||
</Link>
|
||||
</div>
|
||||
<EncaisseChart data={timeseries?.paidByMonth ?? []} />
|
||||
<EncaisseChart
|
||||
data={timeseries?.paidByMonth ?? []}
|
||||
granularity={timeseries?.granularity}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card padding="md" className="flex flex-col">
|
||||
@ -198,7 +202,10 @@ function DashboardPage() {
|
||||
Détails →
|
||||
</Link>
|
||||
</div>
|
||||
<DsoTrendChart data={timeseries?.paidByMonth ?? []} />
|
||||
<DsoTrendChart
|
||||
data={timeseries?.paidByMonth ?? []}
|
||||
granularity={timeseries?.granularity}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
|
||||
@ -18,11 +18,13 @@ import {
|
||||
} from "@/components/charts/PipelineChart";
|
||||
|
||||
type Range = 3 | 6 | 12;
|
||||
type Granularity = "month" | "week";
|
||||
|
||||
type Timeseries = {
|
||||
range: Range;
|
||||
granularity: Granularity;
|
||||
paidByMonth: Array<{
|
||||
month: string;
|
||||
bucket: string;
|
||||
encaisseCents: number;
|
||||
paidCount: number;
|
||||
dsoDays: number;
|
||||
@ -104,12 +106,22 @@ function InsightsPage() {
|
||||
{/* Encaissé — pleine largeur, chart le plus important */}
|
||||
<Card padding="md">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
<EncaisseChart data={ts?.paidByMonth ?? []} height={300} />
|
||||
<EncaisseChart
|
||||
data={ts?.paidByMonth ?? []}
|
||||
granularity={ts?.granularity}
|
||||
height={300}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* DSO + Pipeline côte à côte */}
|
||||
@ -126,7 +138,11 @@ function InsightsPage() {
|
||||
<GlossaryTerm definition={GLOSSARY.lme}>LME</GlossaryTerm> pour le B2B.
|
||||
</p>
|
||||
</div>
|
||||
<DsoTrendChart data={ts?.paidByMonth ?? []} height={260} />
|
||||
<DsoTrendChart
|
||||
data={ts?.paidByMonth ?? []}
|
||||
granularity={ts?.granularity}
|
||||
height={260}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user