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 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 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
|
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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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é" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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]">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user