diff --git a/apps/api/app/services/dashboard.ts b/apps/api/app/services/dashboard.ts index 9a9fa4b..5582acd 100644 --- a/apps/api/app/services/dashboard.ts +++ b/apps/api/app/services/dashboard.ts @@ -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 { - 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 { + granularity: Granularity +}): Promise { const now = await clock.now(params.organizationId) - const firstBucket = now.minus({ months: params.range - 1 }).startOf('month') - const buckets = new Map() - 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() + 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, diff --git a/apps/web/src/components/charts/ClientPaidChart.tsx b/apps/web/src/components/charts/ClientPaidChart.tsx index 83284bf..6bb6b87 100644 --- a/apps/web/src/components/charts/ClientPaidChart.tsx +++ b/apps/web/src/components/charts/ClientPaidChart.tsx @@ -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. ); } + const xInterval = granularity === "week" ? 1 : 0; return ( formatBucketShort(v, granularity)} axisLine={false} tickLine={false} tick={AXIS_TICK_STYLE} + interval={xInterval} dy={4} /> formatBucketLong(v, granularity)} formatValue={(v, key) => key === "encaisseCents" ? formatEuros(v) diff --git a/apps/web/src/components/charts/DsoTrendChart.tsx b/apps/web/src/components/charts/DsoTrendChart.tsx index 895fa86..75691fe 100644 --- a/apps/web/src/components/charts/DsoTrendChart.tsx +++ b/apps/web/src/components/charts/DsoTrendChart.tsx @@ -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 ( formatBucketShort(v, granularity)} axisLine={false} tickLine={false} tick={AXIS_TICK_STYLE} + interval={xInterval} dy={6} /> formatBucketLong(v, granularity)} formatValue={(v, key) => key === "dsoDays" ? `${Math.round(v)} jours` : String(v) } diff --git a/apps/web/src/components/charts/EncaisseChart.tsx b/apps/web/src/components/charts/EncaisseChart.tsx index cd8ee43..e109c2d 100644 --- a/apps/web/src/components/charts/EncaisseChart.tsx +++ b/apps/web/src/components/charts/EncaisseChart.tsx @@ -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 ( @@ -43,18 +54,19 @@ export function EncaisseChart({ data, height = 220 }: EncaisseChartProps) { formatBucketShort(v, granularity)} axisLine={false} tickLine={false} tick={AXIS_TICK_STYLE} + interval={xInterval} dy={6} /> formatBucketLong(v, granularity)} formatValue={(v) => formatEuros(v)} seriesLabel={{ encaisseCents: "Encaissé" }} /> diff --git a/apps/web/src/components/charts/theme.ts b/apps/web/src/components/charts/theme.ts index 407307d..d5e8c67 100644 --- a/apps/web/src/components/charts/theme.ts +++ b/apps/web/src/components/charts/theme.ts @@ -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); +} diff --git a/apps/web/src/routes/_app/clients_.$id.tsx b/apps/web/src/routes/_app/clients_.$id.tsx index c1328e7..e12ed48 100644 --- a/apps/web/src/routes/_app/clients_.$id.tsx +++ b/apps/web/src/routes/_app/clients_.$id.tsx @@ -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() {

- +
diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx index c25400c..65dc0f4 100644 --- a/apps/web/src/routes/_app/index.tsx +++ b/apps/web/src/routes/_app/index.tsx @@ -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 →
- + @@ -198,7 +202,10 @@ function DashboardPage() { Détails → - + diff --git a/apps/web/src/routes/_app/insights.tsx b/apps/web/src/routes/_app/insights.tsx index be16703..826920c 100644 --- a/apps/web/src/routes/_app/insights.tsx +++ b/apps/web/src/routes/_app/insights.tsx @@ -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 */}
- Encaissement mensuel + + {ts?.granularity === "week" + ? "Encaissement hebdomadaire" + : "Encaissement mensuel"} +

- 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."}

- +
{/* DSO + Pipeline côte à côte */} @@ -126,7 +138,11 @@ function InsightsPage() { LME pour le B2B.

- +