From 2d3766cc3debff346f27c1505ffbaa1d54f31213 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Thu, 7 May 2026 10:11:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20dataviz=20coh=C3=A9rente=20D?= =?UTF-8?q?A=20Rubis=20(3=20charts=20+=20page=20Insights)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - Service dashboard.ts : computeTimeseries + computeClientTimeseries (helper fetchPaidByMonth DRY entre les deux). Buckets pré-créés sur N mois pour pas afficher de "trous" quand un mois n'a aucun paiement. - GET /dashboard/timeseries?range=3|6|12 (paidByMonth + pipelineByStatus) - GET /clients/:id/timeseries?range=3|6|12 (paidByMonth filtré) Frontend — Recharts (43 deps, ~50KB gzip) - components/charts/theme.ts : palette stricte (rubis + neutres chauds, pas de bleu/vert), couleurs statuts cohérentes avec les badges côté liste, format fr-FR pour les axes/tooltips - ChartTooltip themed : carte cream + bordure rubis-glow, font Inter, tabular-nums, série label override - EncaisseChart (area, dégradé rubis-glow → transparent) - DsoTrendChart (line ink + référence pointillée à 30j = norme LME) - PipelineChart (donut avec total au centre + PipelineLegend séparée) - ClientPaidChart (bar chart compact pour fiche client) Wiring - Dashboard / : encaissé + DSO côte à côte, pipeline + top retards en dessous - Fiche client /clients/:id : mini bar chart "encaissés sur 6 mois" entre les stats et la liste factures - Page /insights : version pleine largeur des 3 charts + range selector 3m/6m/12m + 3 cards récap (encaissé total, factures payées, DSO moyen). Lien "Insights" ajouté au sidebar desktop (icône TrendingUp). Co-Authored-By: Claude Opus 4.7 --- .../api/app/controllers/clients_controller.ts | 34 +++ .../app/controllers/dashboard_controller.ts | 29 +- apps/api/app/services/dashboard.ts | 138 +++++++++ apps/api/commands/seed_demo.ts | 14 +- apps/api/start/routes.ts | 7 + apps/web/package.json | 1 + .../src/components/charts/ChartTooltip.tsx | 68 +++++ .../src/components/charts/ClientPaidChart.tsx | 91 ++++++ .../src/components/charts/DsoTrendChart.tsx | 105 +++++++ .../src/components/charts/EncaisseChart.tsx | 76 +++++ .../src/components/charts/PipelineChart.tsx | 120 ++++++++ apps/web/src/components/charts/theme.ts | 81 +++++ apps/web/src/components/layout/AppSidebar.tsx | 2 + .../plans/wizard/AiGenerateModal.tsx | 2 +- apps/web/src/routes/_app/clients_.$id.tsx | 30 ++ apps/web/src/routes/_app/index.tsx | 98 +++++- apps/web/src/routes/_app/insights.tsx | 190 ++++++++++++ apps/web/src/routes/_app/plans_.nouveau.tsx | 2 +- pnpm-lock.yaml | 288 ++++++++++++++++++ 19 files changed, 1364 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/charts/ChartTooltip.tsx create mode 100644 apps/web/src/components/charts/ClientPaidChart.tsx create mode 100644 apps/web/src/components/charts/DsoTrendChart.tsx create mode 100644 apps/web/src/components/charts/EncaisseChart.tsx create mode 100644 apps/web/src/components/charts/PipelineChart.tsx create mode 100644 apps/web/src/components/charts/theme.ts create mode 100644 apps/web/src/routes/_app/insights.tsx diff --git a/apps/api/app/controllers/clients_controller.ts b/apps/api/app/controllers/clients_controller.ts index fd74d50..bc179bb 100644 --- a/apps/api/app/controllers/clients_controller.ts +++ b/apps/api/app/controllers/clients_controller.ts @@ -4,8 +4,14 @@ import ClientTransformer from '#transformers/client_transformer' import InvoiceTransformer from '#transformers/invoice_transformer' import { createClientValidator, updateClientValidator } from '#validators/client' import { bulkComputeClientStats } from '#services/client_stats' +import { computeClientTimeseries, type RangeMonths } from '#services/dashboard' import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' +import vine from '@vinejs/vine' + +const timeseriesValidator = vine.create({ + range: vine.number().in([3, 6, 12]).optional(), +}) // Priorité d'affichage : ce qui est actionnable en haut. const INVOICE_STATUS_PRIORITY: Record = { @@ -176,6 +182,34 @@ export default class ClientsController { return response.status(201).json({ data: serializeClient(created) }) } + /** + * GET /clients/:id/timeseries?range=6 — encaissé mensuel pour ce client. + * Utilisé par le mini-chart sur la fiche client (santé du compte). + */ + async timeseries({ auth, request, params, response }: HttpContext) { + const organizationId = requireOrgId(auth) + + const exists = await Client.query() + .where('organization_id', organizationId) + .where('id', params.id) + .first() + if (!exists) { + throw new Exception('Client introuvable', { status: 404, code: 'not_found' }) + } + + const { range } = await request.validateUsing(timeseriesValidator, { + data: { + range: request.input('range') ? Number(request.input('range')) : undefined, + }, + }) + const data = await computeClientTimeseries( + organizationId, + params.id, + (range ?? 6) as RangeMonths + ) + return response.json({ data }) + } + /** * PATCH /clients/:id — édition partielle. */ diff --git a/apps/api/app/controllers/dashboard_controller.ts b/apps/api/app/controllers/dashboard_controller.ts index e535bfb..5a68585 100644 --- a/apps/api/app/controllers/dashboard_controller.ts +++ b/apps/api/app/controllers/dashboard_controller.ts @@ -1,7 +1,17 @@ import ActivityEvent from '#models/activity_event' -import { computeKpis, topLatePayers } from '#services/dashboard' +import { + computeKpis, + computeTimeseries, + topLatePayers, + type RangeMonths, +} from '#services/dashboard' import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' +import vine from '@vinejs/vine' + +const timeseriesValidator = vine.create({ + range: vine.number().in([3, 6, 12]).optional(), +}) const ACTIVITY_DEFAULT_LIMIT = 20 @@ -62,4 +72,21 @@ export default class DashboardController { const data = await topLatePayers(organizationId) return response.json({ data }) } + + /** + * GET /dashboard/timeseries?range=6 + * + * Séries temporelles pour les graphes (encaissé mensuel + DSO mensuel + * + pipeline par statut). Range : 3, 6 ou 12 mois (défaut 6). + */ + async timeseries({ auth, request, response }: HttpContext) { + const organizationId = requireOrgId(auth) + const { range } = await request.validateUsing(timeseriesValidator, { + data: { + range: request.input('range') ? Number(request.input('range')) : undefined, + }, + }) + const data = await computeTimeseries(organizationId, (range ?? 6) as RangeMonths) + return response.json({ data }) + } } diff --git a/apps/api/app/services/dashboard.ts b/apps/api/app/services/dashboard.ts index 30b79e1..825ed68 100644 --- a/apps/api/app/services/dashboard.ts +++ b/apps/api/app/services/dashboard.ts @@ -148,3 +148,141 @@ export async function topLatePayers( lateInvoicesCount: r.late_invoices_count, })) } + +// =========================================================================== +// Time series — pour les graphes du dashboard et de /insights +// =========================================================================== + +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). */ + encaisseCents: number + /** Nombre de factures payées sur le mois. */ + paidCount: number + /** DSO moyen sur le mois (jours, 0 si aucun paiement). */ + dsoDays: number +} + +export type PipelineSlice = { + status: 'pending' | 'awaiting_user_confirmation' | 'in_relance' | 'litigation' | 'paid' + count: number + amountCents: number +} + +export type DashboardTimeseries = { + range: RangeMonths + paidByMonth: PaidByMonthPoint[] + pipelineByStatus: PipelineSlice[] +} + +/** + * Calcule les séries temporelles pour le dashboard / insights. + * + * `paidByMonth` : N derniers mois (range), 1 ligne par mois même si vide + * (sinon les charts affichent des "trous"). + * + * `pipelineByStatus` : breakdown du portefeuille (count + montant) — pour + * donut/stacked-bar. Cancelled exclus pour réduire le bruit. + */ +export async function computeTimeseries( + organizationId: string, + range: RangeMonths = 6 +): Promise { + const paidByMonth = await fetchPaidByMonth({ organizationId, range }) + + const pipelineRows = (await db + .from('invoices') + .where('organization_id', organizationId) + .select('status') + .select(db.raw('count(*)::int as count')) + .select(db.raw('coalesce(sum(amount_ttc_cents), 0)::int as amount_cents')) + .groupBy('status')) as Array<{ + status: PipelineSlice['status'] | 'cancelled' + count: number + amount_cents: number + }> + + const pipelineOrder: PipelineSlice['status'][] = [ + 'pending', + 'awaiting_user_confirmation', + 'in_relance', + 'litigation', + 'paid', + ] + const pipelineMap = new Map(pipelineRows.map((r) => [r.status, r])) + const pipelineByStatus: PipelineSlice[] = pipelineOrder.map((status) => { + const r = pipelineMap.get(status) + return { status, count: r?.count ?? 0, amountCents: r?.amount_cents ?? 0 } + }) + + return { range, paidByMonth, pipelineByStatus } +} + +/** Variante par client — on filtre paidByMonth sur un client_id. */ +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 } +} + +/** + * Helper interne — DRY entre computeTimeseries et computeClientTimeseries. + * Renvoie N buckets mensuels (range derniers mois inclus) avec encaisse/count/dso. + */ +async function fetchPaidByMonth(params: { + organizationId: string + clientId?: string + range: RangeMonths +}): Promise { + const now = DateTime.utc() + 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 }) + } + + const query = db + .from('invoices') + .where('organization_id', params.organizationId) + .where('status', 'paid') + .where('paid_at', '>=', firstBucket.toJSDate()) + + if (params.clientId) query.where('client_id', params.clientId) + + const rows = (await query + .select( + db.raw(`to_char(date_trunc('month', paid_at), 'YYYY-MM-01') as month`), + 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 + 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, + encaisseCents: r.encaisse_cents, + paidCount: r.paid_count, + dsoDays: r.dso_days, + }) + } + + return Array.from(buckets.values()) +} diff --git a/apps/api/commands/seed_demo.ts b/apps/api/commands/seed_demo.ts index 0717c71..815cf49 100644 --- a/apps/api/commands/seed_demo.ts +++ b/apps/api/commands/seed_demo.ts @@ -1,4 +1,4 @@ -import { BaseCommand, args, flags } from '@adonisjs/core/ace' +import { BaseCommand, flags } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' import db from '@adonisjs/lucid/services/db' @@ -28,8 +28,11 @@ export default class SeedDemo extends BaseCommand { startApp: true, } - @args.string({ description: 'Email du user dont on peuple l\'org', required: false }) - declare email: string | undefined + @flags.string({ + description: "Email du user dont on peuple l'org", + required: true, + }) + declare email: string @flags.boolean({ description: @@ -39,12 +42,13 @@ export default class SeedDemo extends BaseCommand { declare reset: boolean @flags.string({ - description: "Nom à donner à l'organisation (ex. 'Maçonnerie Dupont'). Si vide, on ne touche pas.", + description: + "Nom à donner à l'organisation (ex. 'Arthur Barré'). Si vide, on garde le nom existant ou fallback.", }) declare orgName?: string async run() { - const email = this.email ?? this.parsed.flags.email + const email = this.email if (!email) { this.logger.error('Argument requis : --email ') this.exitCode = 1 diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index c0ed991..a62eef5 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -103,6 +103,10 @@ router router.get('', [controllers.Clients, 'index']).as('index') router.post('', [controllers.Clients, 'store']).as('store') router.get(':id', [controllers.Clients, 'show']).as('show').where('id', router.matchers.uuid()) + router + .get(':id/timeseries', [controllers.Clients, 'timeseries']) + .as('timeseries') + .where('id', router.matchers.uuid()) router.patch(':id', [controllers.Clients, 'update']).as('update').where('id', router.matchers.uuid()) }) .prefix('clients') @@ -145,6 +149,9 @@ router router.get('kpis', [controllers.Dashboard, 'kpis']).as('kpis') router.get('activity', [controllers.Dashboard, 'activity']).as('activity') router.get('top-late', [controllers.Dashboard, 'topLate']).as('top-late') + router + .get('timeseries', [controllers.Dashboard, 'timeseries']) + .as('timeseries') }) .prefix('dashboard') .as('dashboard') diff --git a/apps/web/package.json b/apps/web/package.json index bddfc5b..f3a0467 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "lucide-react": "^0.475.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "recharts": "^3.8.1", "sonner": "^1.7.4", "tailwind-merge": "^3.0.1", "zod": "^3.24.1" diff --git a/apps/web/src/components/charts/ChartTooltip.tsx b/apps/web/src/components/charts/ChartTooltip.tsx new file mode 100644 index 0000000..43f5406 --- /dev/null +++ b/apps/web/src/components/charts/ChartTooltip.tsx @@ -0,0 +1,68 @@ +import type { TooltipProps } from "recharts"; +import { cn } from "@/lib/utils"; + +/** + * Tooltip thémé Rubis — petite card cream + bordure rubis-glow. + * Reçoit en props un formatter pour personnaliser l'affichage des + * valeurs (ex. "1 240,00 €" pour les montants, "12 j" pour le DSO). + */ +type ChartTooltipProps = TooltipProps & { + /** Convertit le label X (ex. "2026-04-01" → "avril 2026"). */ + formatLabel?: (label: string) => string; + /** Convertit chaque valeur. Reçoit (value, dataKey). */ + formatValue?: (value: number, dataKey: string) => string; + /** Label personnalisé pour chaque dataKey. Défaut : dataKey lui-même. */ + seriesLabel?: Record; + className?: string; +}; + +export function ChartTooltip({ + active, + payload, + label, + formatLabel, + formatValue, + seriesLabel, + className, +}: ChartTooltipProps) { + if (!active || !payload || payload.length === 0) return null; + + return ( +
+ {label && ( +

+ {formatLabel ? formatLabel(String(label)) : String(label)} +

+ )} +
    + {payload.map((p, i) => { + const dataKey = String(p.dataKey ?? p.name ?? i); + const value = typeof p.value === "number" ? p.value : Number(p.value); + return ( +
  • +
  • + ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/charts/ClientPaidChart.tsx b/apps/web/src/components/charts/ClientPaidChart.tsx new file mode 100644 index 0000000..83284bf --- /dev/null +++ b/apps/web/src/components/charts/ClientPaidChart.tsx @@ -0,0 +1,91 @@ +import { useMemo } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { formatEuros } from "@/lib/format"; +import { + AXIS_TICK_STYLE, + chartColors, + formatMonthLong, + formatMonthShort, +} 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. + */ +type ClientPaidChartProps = { + data: Array<{ month: string; encaisseCents: number; paidCount: number }>; + height?: number; +}; + +export function ClientPaidChart({ data, height = 140 }: ClientPaidChartProps) { + const c = chartColors(); + + // Si aucun paiement sur la période, on affiche un message — un chart vide + // (que des barres à 0) raconte rien. + const hasAnyPaid = useMemo( + () => data.some((d) => d.paidCount > 0), + [data], + ); + + if (!hasAnyPaid) { + return ( +
+ Aucun paiement reçu de ce client sur les 6 derniers mois. +
+ ); + } + + return ( + + + + + + + key === "encaisseCents" + ? formatEuros(v) + : `${v} facture${v > 1 ? "s" : ""}` + } + seriesLabel={{ + encaisseCents: "Encaissé", + paidCount: "Paiements", + }} + /> + } + cursor={{ fill: c.cream2, opacity: 0.6 }} + /> + + + + ); +} diff --git a/apps/web/src/components/charts/DsoTrendChart.tsx b/apps/web/src/components/charts/DsoTrendChart.tsx new file mode 100644 index 0000000..895fa86 --- /dev/null +++ b/apps/web/src/components/charts/DsoTrendChart.tsx @@ -0,0 +1,105 @@ +import { + CartesianGrid, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { + AXIS_TICK_STYLE, + chartColors, + formatMonthLong, + formatMonthShort, +} 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). + */ +type DsoTrendChartProps = { + data: Array<{ month: string; dsoDays: number; paidCount: number }>; + height?: number; + /** Référence à afficher en pointillé (30j = LME standard). */ + reference?: number; +}; + +export function DsoTrendChart({ + data, + 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). + const enriched = data.map((d) => ({ + ...d, + dsoDays: d.paidCount > 0 ? d.dsoDays : null, + })); + + return ( + + + + + `${v}j`} + domain={[0, "dataMax + 5"]} + /> + + + key === "dsoDays" ? `${Math.round(v)} jours` : String(v) + } + seriesLabel={{ dsoDays: "DSO moyen" }} + /> + } + cursor={{ stroke: c.ink3, strokeWidth: 1, strokeDasharray: "4 4" }} + /> + + + + ); +} diff --git a/apps/web/src/components/charts/EncaisseChart.tsx b/apps/web/src/components/charts/EncaisseChart.tsx new file mode 100644 index 0000000..cd8ee43 --- /dev/null +++ b/apps/web/src/components/charts/EncaisseChart.tsx @@ -0,0 +1,76 @@ +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { formatEuros } from "@/lib/format"; +import { + AXIS_TICK_STYLE, + chartColors, + formatMonthLong, + formatMonthShort, +} from "./theme"; +import { ChartTooltip } from "./ChartTooltip"; + +/** + * Encaissé mensuel — area chart avec dégradé rubis. + * X = mois (avr, mai, juin…), Y = euros encaissés. + * 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). + */ +type EncaisseChartProps = { + data: Array<{ month: string; encaisseCents: number }>; + /** Hauteur fixe en px (défaut 220). */ + height?: number; +}; + +export function EncaisseChart({ data, height = 220 }: EncaisseChartProps) { + const c = chartColors(); + return ( + + + + + + + + + + + + formatEuros(v)} + seriesLabel={{ encaisseCents: "Encaissé" }} + /> + } + cursor={{ stroke: c.rubis, strokeWidth: 1, strokeDasharray: "4 4" }} + /> + + + + ); +} diff --git a/apps/web/src/components/charts/PipelineChart.tsx b/apps/web/src/components/charts/PipelineChart.tsx new file mode 100644 index 0000000..b26f8eb --- /dev/null +++ b/apps/web/src/components/charts/PipelineChart.tsx @@ -0,0 +1,120 @@ +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; + +import { formatEuros } from "@/lib/format"; +import { chartColors, STATUS_COLOR, STATUS_LABEL } from "./theme"; +import { ChartTooltip } from "./ChartTooltip"; + +/** + * Pipeline factures par statut — donut chart. + * + * Affiche le nombre de factures (count) par statut, avec un total + * centré au milieu du donut. Les montants sont dans le tooltip. + * + * Les statuts sans facture sont filtrés (sinon le donut a des slices + * de 0 qui chargent le visuel). + */ +type PipelineChartProps = { + data: Array<{ status: string; count: number; amountCents: number }>; + height?: number; +}; + +export function PipelineChart({ data, height = 220 }: PipelineChartProps) { + const c = chartColors(); + const filtered = data.filter((d) => d.count > 0); + const total = filtered.reduce((s, d) => s + d.count, 0); + + if (total === 0) { + return ( +
+ Aucune facture pour l'instant — créez-en une depuis l'écran factures. +
+ ); + } + + return ( +
+ + + + key === "count" ? `${v} factures` : formatEuros(v) + } + seriesLabel={{ count: "Nombre", amountCents: "Montant" }} + /> + } + /> + + {filtered.map((entry) => ( + + ))} + + + + + {/* Total centré au milieu du donut */} +
+

+ {total} +

+

+ Factures +

+
+
+ ); +} + +/** + * Légende compacte à afficher à côté du donut. Liste les statuts non-zero + * avec leur couleur, count et montant. + */ +export function PipelineLegend({ + data, +}: { + data: Array<{ status: string; count: number; amountCents: number }>; +}) { + const filtered = data.filter((d) => d.count > 0); + return ( +
    + {filtered.map((d) => ( +
  • +
  • + ))} +
+ ); +} diff --git a/apps/web/src/components/charts/theme.ts b/apps/web/src/components/charts/theme.ts new file mode 100644 index 0000000..9b9c947 --- /dev/null +++ b/apps/web/src/components/charts/theme.ts @@ -0,0 +1,81 @@ +/** + * Tokens et primitives partagés par tous les charts Rubis. + * On reste **strict** sur la palette : que des couleurs marque (rubis + + * neutres chauds), pas de bleu/vert/catégoriel générique de BI dashboard. + */ + +import type { CSSProperties } from "react"; + +/** Lit la valeur d'une CSS custom-property posée par @theme dans app.css. */ +function cssVar(name: string): string { + // Côté SSR / tests, document est absent — on fallback hex. + if (typeof window === "undefined") return ""; + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +/** + * Couleurs résolues à l'usage (pas en module-scope car les CSS vars + * peuvent ne pas être prêtes au tout premier import). Cf. `chartColors()`. + */ +export function chartColors() { + return { + rubis: cssVar("--color-rubis") || "#9F1239", + rubisDeep: cssVar("--color-rubis-deep") || "#771328", + rubisLight: cssVar("--color-rubis-light") || "#C9415C", + rubisGlow: cssVar("--color-rubis-glow") || "#FBE4EA", + cream: cssVar("--color-cream") || "#FAF7F2", + cream2: cssVar("--color-cream-2") || "#F5EFE7", + line: cssVar("--color-line") || "#E8E0D6", + ink: cssVar("--color-ink") || "#1A1410", + ink2: cssVar("--color-ink-2") || "#4F4640", + ink3: cssVar("--color-ink-3") || "#8A7F76", + }; +} + +/** + * Couleur d'un statut de facture — réutilise l'identité des badges côté + * liste pour que pipeline charts et tableaux racontent la même histoire. + */ +export const STATUS_COLOR: Record = { + pending: "#E8E0D6", // line — neutre, en attente + awaiting_user_confirmation: "#FBE4EA", // rubis-glow + in_relance: "#9F1239", // rubis primaire + litigation: "#1A1410", // ink — sérieux + paid: "#771328", // rubis-deep +}; + +export const STATUS_LABEL: Record = { + pending: "En attente", + awaiting_user_confirmation: "Check-in en cours", + in_relance: "En relance", + litigation: "Litige", + paid: "Encaissé", +}; + +/** Style label commun pour les axes (Inter, tabular-nums, ink-3). */ +export const AXIS_TICK_STYLE: CSSProperties = { + fontFamily: "var(--font-sans)", + fontSize: 11, + fontFeatureSettings: '"tnum"', + fill: "var(--color-ink-3)", +}; + +/** 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 { + // "2026-04-01" → date locale (no need for parseISO, Date est suffisant) + const d = new Date(monthIso); + if (Number.isNaN(d.getTime())) return monthIso; + return MONTH_FORMAT.format(d).replace(".", ""); +} + +/** Format mois long pour les tooltips. */ +const MONTH_LONG = new Intl.DateTimeFormat("fr-FR", { + month: "long", + year: "numeric", +}); +export function formatMonthLong(monthIso: string): string { + const d = new Date(monthIso); + if (Number.isNaN(d.getTime())) return monthIso; + return MONTH_LONG.format(d); +} diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx index 3d9c035..b7ca64a 100644 --- a/apps/web/src/components/layout/AppSidebar.tsx +++ b/apps/web/src/components/layout/AppSidebar.tsx @@ -5,6 +5,7 @@ import { ListChecks, Users, Settings, + TrendingUp, } from "lucide-react"; import { Brand } from "@/components/brand/Brand"; @@ -36,6 +37,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) } label="Factures" /> } label="Plans de relance" /> } label="Clients" /> + } label="Insights" /> } label="Paramètres" /> diff --git a/apps/web/src/components/plans/wizard/AiGenerateModal.tsx b/apps/web/src/components/plans/wizard/AiGenerateModal.tsx index ebc6866..3aadc4e 100644 --- a/apps/web/src/components/plans/wizard/AiGenerateModal.tsx +++ b/apps/web/src/components/plans/wizard/AiGenerateModal.tsx @@ -84,7 +84,7 @@ export function AiGenerateModal({ - + Générer avec l'IA diff --git a/apps/web/src/routes/_app/clients_.$id.tsx b/apps/web/src/routes/_app/clients_.$id.tsx index 42eea7e..c1328e7 100644 --- a/apps/web/src/routes/_app/clients_.$id.tsx +++ b/apps/web/src/routes/_app/clients_.$id.tsx @@ -28,9 +28,20 @@ import { Card } from "@/components/ui/Card"; import { Eyebrow } from "@/components/ui/Eyebrow"; import { Textarea } from "@/components/ui/Textarea"; import { StatusBadge } from "@/components/ui/StatusBadge"; +import { ClientPaidChart } from "@/components/charts/ClientPaidChart"; import type { InvoiceListItem } from "@/components/factures/InvoiceTable"; import type { ClientWithStats } from "@/components/clients/ClientTable"; +type ClientTimeseries = { + range: 3 | 6 | 12; + paidByMonth: Array<{ + month: string; + encaisseCents: number; + paidCount: number; + dsoDays: number; + }>; +}; + type ClientDetail = ClientWithStats & { invoices: InvoiceListItem[]; }; @@ -55,6 +66,12 @@ function ClientDetailPage() { queryFn: () => api.get(`/api/v1/clients/${id}`), }); + const { data: timeseries } = useQuery({ + queryKey: ["clients", id, "timeseries", 6] as const, + queryFn: () => + api.get(`/api/v1/clients/${id}/timeseries?range=6`), + }); + // Notes : édition locale + sauvegarde sur blur. Garde le draft local pour // ne pas refetch écraser ce que l'user est en train de taper. const [notesDraft, setNotesDraft] = useState(""); @@ -190,6 +207,19 @@ function ClientDetailPage() { /> + {/* Mini chart "encaissé sur 6 mois" pour ce client */} + +
+
+ Paiements · 6 mois +

+ Évolution des encaissements de ce client. +

+
+
+ +
+
{/* Liste des factures du client */}
diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx index ad84987..02fe888 100644 --- a/apps/web/src/routes/_app/index.tsx +++ b/apps/web/src/routes/_app/index.tsx @@ -1,12 +1,14 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { Camera, Plus, ArrowDownRight } from "lucide-react"; +import { Camera, Plus, ArrowDownRight, ArrowRight } from "lucide-react"; import { api } from "@/lib/api"; import { queryKeys } from "@/lib/queryKeys"; import { formatEuros } from "@/lib/format"; import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Eyebrow } from "@/components/ui/Eyebrow"; import { RubisHero } from "@/components/dashboard/RubisHero"; import { KpiCard } from "@/components/dashboard/KpiCard"; import { @@ -17,6 +19,27 @@ import { TopLatePayers, type LatePayer, } from "@/components/dashboard/TopLatePayers"; +import { EncaisseChart } from "@/components/charts/EncaisseChart"; +import { DsoTrendChart } from "@/components/charts/DsoTrendChart"; +import { + PipelineChart, + PipelineLegend, +} from "@/components/charts/PipelineChart"; + +type Timeseries = { + range: 3 | 6 | 12; + paidByMonth: Array<{ + month: string; + encaisseCents: number; + paidCount: number; + dsoDays: number; + }>; + pipelineByStatus: Array<{ + status: string; + count: number; + amountCents: number; + }>; +}; type DashboardKpis = { rubisCount: number; @@ -61,6 +84,11 @@ function DashboardPage() { queryFn: () => api.get("/api/v1/dashboard/top-late"), }); + const { data: timeseries } = useQuery({ + queryKey: ["dashboard", "timeseries", 6] as const, + queryFn: () => api.get("/api/v1/dashboard/timeseries?range=6"), + }); + return (
{/* Actions mobile : visibles seulement sur mobile (le topbar mobile montre la marque). */} @@ -127,11 +155,73 @@ function DashboardPage() { />
-
- + {/* Charts — encaissé + DSO côte à côte, puis pipeline en pleine largeur. + Cliquable vers /insights pour la version avec range selector. */} +
+ +
+
+ Encaissé · 6 mois +

+ Argent récupéré chaque mois. +

+
+ + Détails → + +
+ +
+ + +
+
+ DSO · 6 mois +

+ Délai moyen entre émission et paiement. +

+
+ + Détails → + +
+ +
+
+ +
+ +
+ Pipeline factures + + Voir + +
+
+ + +
+
+
+
+ +
+ {/* Petite signature visuelle en bas — discret, juste pour aérer. */}

+
+
+ Insights +

+ Comprendre vos flux +

+

+ Vue d'ensemble du portefeuille et des encaissements. Filtre la + période pour comparer. +

+
+ + +
+ + {/* Récap chiffré au-dessus des charts pour ancrer la lecture. */} +
+ + + 0 ? `${dsoMoyen} j` : "—"} /> +
+ + {/* Encaissé — pleine largeur, chart le plus important */} + +
+ Encaissement mensuel +

+ Total facturé encaissé chaque mois sur la période sélectionnée. +

+
+ +
+ + {/* DSO + Pipeline côte à côte */} +
+ +
+ Délai de paiement (DSO) +

+ Jours moyens entre l'émission et le paiement. La référence + à 30 j est la norme LME pour le B2B. +

+
+ +
+ + +
+ Pipeline factures +

+ Répartition par statut sur l'ensemble du portefeuille. +

+
+
+ + +
+
+
+
+ ); +} + +function RangePicker({ + value, + onChange, +}: { + value: Range; + onChange: (r: Range) => void; +}) { + return ( +
+ {RANGES.map((r) => { + const active = r.value === value; + return ( + + ); + })} +
+ ); +} + +function SummaryCard({ label, value }: { label: string; value: string }) { + return ( + +

+ {label} +

+

+ {value} +

+
+ ); +} diff --git a/apps/web/src/routes/_app/plans_.nouveau.tsx b/apps/web/src/routes/_app/plans_.nouveau.tsx index da17eb1..9373f69 100644 --- a/apps/web/src/routes/_app/plans_.nouveau.tsx +++ b/apps/web/src/routes/_app/plans_.nouveau.tsx @@ -628,7 +628,7 @@ function StepMessages({ Étape {selectedIdx + 1} · J{selected.offsetDays >= 0 ? "+" : ""} {selected.offsetDays}

-
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 943f21a..cf3abe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1) sonner: specifier: ^1.7.4 version: 1.7.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1931,6 +1934,17 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.17': resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2387,6 +2401,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stylistic/eslint-plugin@5.10.0': resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2786,6 +2803,33 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2845,6 +2889,9 @@ packages: '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} @@ -3319,6 +3366,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -3350,6 +3441,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -3503,6 +3597,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -4005,6 +4102,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.7: + resolution: {integrity: sha512-LFVFtAROHcDy1er5UI6nodRFnZ2SgdCXhfNSI+DpObO8N7Pur/muBGsjzH5wpnFHCYhYVQxZskCkV4koQ//3/Q==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -4038,6 +4141,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + interpret@2.2.0: resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} engines: {node: '>= 0.10'} @@ -4856,6 +4963,18 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -4914,6 +5033,14 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + rechoir@0.8.0: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} @@ -4930,6 +5057,14 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -4945,6 +5080,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5302,6 +5440,9 @@ packages: timekeeper@2.3.1: resolution: {integrity: sha512-LeQRS7/4JcC0PgdSFnfUiStQEdiuySlCj/5SJ18D+T1n9BoY7PxKFfCwLulpHXoLUFr67HxBddQdEX47lDGx1g==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -5520,6 +5661,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -7598,6 +7742,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.7 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.5 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true @@ -8073,6 +8229,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@stylistic/eslint-plugin@5.10.0(eslint@10.3.0(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) @@ -8441,6 +8599,30 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} @@ -8502,6 +8684,8 @@ snapshots: '@types/node': 25.6.0 form-data: 4.0.5 + '@types/use-sync-external-store@0.0.6': {} + '@types/validator@13.15.10': {} '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)': @@ -8992,6 +9176,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -9011,6 +9233,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} decompress-response@6.0.0: @@ -9122,6 +9346,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.3 + es-toolkit@1.46.1: {} + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -9663,6 +9889,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.7: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -9684,6 +9914,8 @@ snapshots: ini@1.3.8: {} + internmap@2.0.3: {} + interpret@2.2.0: {} ioredis@5.10.1: @@ -10452,6 +10684,15 @@ snapshots: react-is@18.3.1: {} + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): dependencies: react: 19.2.5 @@ -10509,6 +10750,26 @@ snapshots: real-require@0.2.0: {} + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.46.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-is: 18.3.1 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.5) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + rechoir@0.8.0: dependencies: resolve: 1.22.12 @@ -10524,6 +10785,12 @@ snapshots: dependencies: redis-errors: 1.2.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect-metadata@0.2.2: {} regexp-tree@0.1.27: {} @@ -10534,6 +10801,8 @@ snapshots: require-directory@2.1.1: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -10907,6 +11176,8 @@ snapshots: timekeeper@2.3.1: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -11094,6 +11365,23 @@ snapshots: vary@1.1.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4): dependencies: cac: 6.7.14