feat(dashboard): dataviz cohérente DA Rubis (3 charts + page Insights)

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 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 10:11:45 +02:00
parent 32fcb02108
commit 2d3766cc3d
19 changed files with 1364 additions and 12 deletions

View File

@ -4,8 +4,14 @@ import ClientTransformer from '#transformers/client_transformer'
import InvoiceTransformer from '#transformers/invoice_transformer' import InvoiceTransformer from '#transformers/invoice_transformer'
import { createClientValidator, updateClientValidator } from '#validators/client' import { createClientValidator, updateClientValidator } from '#validators/client'
import { bulkComputeClientStats } from '#services/client_stats' import { bulkComputeClientStats } from '#services/client_stats'
import { computeClientTimeseries, type RangeMonths } from '#services/dashboard'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions' 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. // Priorité d'affichage : ce qui est actionnable en haut.
const INVOICE_STATUS_PRIORITY: Record<string, number> = { const INVOICE_STATUS_PRIORITY: Record<string, number> = {
@ -176,6 +182,34 @@ export default class ClientsController {
return response.status(201).json({ data: serializeClient(created) }) 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. * PATCH /clients/:id édition partielle.
*/ */

View File

@ -1,7 +1,17 @@
import ActivityEvent from '#models/activity_event' 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 type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions' 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 const ACTIVITY_DEFAULT_LIMIT = 20
@ -62,4 +72,21 @@ export default class DashboardController {
const data = await topLatePayers(organizationId) const data = await topLatePayers(organizationId)
return response.json({ data }) 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 })
}
} }

View File

@ -148,3 +148,141 @@ export async function topLatePayers(
lateInvoicesCount: r.late_invoices_count, 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<DashboardTimeseries> {
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<PaidByMonthPoint[]> {
const now = DateTime.utc()
const firstBucket = now.minus({ months: params.range - 1 }).startOf('month')
const buckets = new Map<string, PaidByMonthPoint>()
for (let i = 0; i < params.range; i++) {
const m = firstBucket.plus({ months: i }).toFormat('yyyy-LL-01')
buckets.set(m, { month: m, encaisseCents: 0, paidCount: 0, dsoDays: 0 })
}
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())
}

View File

@ -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 type { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db' import db from '@adonisjs/lucid/services/db'
@ -28,8 +28,11 @@ export default class SeedDemo extends BaseCommand {
startApp: true, startApp: true,
} }
@args.string({ description: 'Email du user dont on peuple l\'org', required: false }) @flags.string({
declare email: string | undefined description: "Email du user dont on peuple l'org",
required: true,
})
declare email: string
@flags.boolean({ @flags.boolean({
description: description:
@ -39,12 +42,13 @@ export default class SeedDemo extends BaseCommand {
declare reset: boolean declare reset: boolean
@flags.string({ @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 declare orgName?: string
async run() { async run() {
const email = this.email ?? this.parsed.flags.email const email = this.email
if (!email) { if (!email) {
this.logger.error('Argument requis : --email <user-email>') this.logger.error('Argument requis : --email <user-email>')
this.exitCode = 1 this.exitCode = 1

View File

@ -103,6 +103,10 @@ router
router.get('', [controllers.Clients, 'index']).as('index') router.get('', [controllers.Clients, 'index']).as('index')
router.post('', [controllers.Clients, 'store']).as('store') router.post('', [controllers.Clients, 'store']).as('store')
router.get(':id', [controllers.Clients, 'show']).as('show').where('id', router.matchers.uuid()) 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()) router.patch(':id', [controllers.Clients, 'update']).as('update').where('id', router.matchers.uuid())
}) })
.prefix('clients') .prefix('clients')
@ -145,6 +149,9 @@ router
router.get('kpis', [controllers.Dashboard, 'kpis']).as('kpis') router.get('kpis', [controllers.Dashboard, 'kpis']).as('kpis')
router.get('activity', [controllers.Dashboard, 'activity']).as('activity') router.get('activity', [controllers.Dashboard, 'activity']).as('activity')
router.get('top-late', [controllers.Dashboard, 'topLate']).as('top-late') router.get('top-late', [controllers.Dashboard, 'topLate']).as('top-late')
router
.get('timeseries', [controllers.Dashboard, 'timeseries'])
.as('timeseries')
}) })
.prefix('dashboard') .prefix('dashboard')
.as('dashboard') .as('dashboard')

View File

@ -38,6 +38,7 @@
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"recharts": "^3.8.1",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"zod": "^3.24.1" "zod": "^3.24.1"

View File

@ -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<number, string> & {
/** 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<string, string>;
className?: string;
};
export function ChartTooltip({
active,
payload,
label,
formatLabel,
formatValue,
seriesLabel,
className,
}: ChartTooltipProps) {
if (!active || !payload || payload.length === 0) return null;
return (
<div
className={cn(
"rounded-default border border-rubis-glow bg-white px-3 py-2",
"shadow-card text-[12px] font-sans",
className,
)}
>
{label && (
<p className="font-display text-[12px] font-semibold text-ink mb-1 capitalize">
{formatLabel ? formatLabel(String(label)) : String(label)}
</p>
)}
<ul className="space-y-0.5">
{payload.map((p, i) => {
const dataKey = String(p.dataKey ?? p.name ?? i);
const value = typeof p.value === "number" ? p.value : Number(p.value);
return (
<li key={i} className="flex items-center gap-2 text-ink-2 tabular-nums">
<span
aria-hidden="true"
className="size-2 rounded-full"
style={{ backgroundColor: String(p.color) || "var(--color-rubis)" }}
/>
<span className="text-ink-3 mr-1">
{seriesLabel?.[dataKey] ?? dataKey} :
</span>
<strong className="font-semibold text-ink">
{formatValue
? formatValue(Number.isFinite(value) ? value : 0, dataKey)
: String(p.value)}
</strong>
</li>
);
})}
</ul>
</div>
);
}

View File

@ -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 (
<div
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.
</div>
);
}
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart data={data} margin={{ top: 4, right: 4, left: 4, bottom: 0 }}>
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickFormatter={formatMonthShort}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
dy={4}
/>
<YAxis hide domain={[0, "dataMax + 100"]} />
<Tooltip
content={
<ChartTooltip
formatLabel={formatMonthLong}
formatValue={(v, key) =>
key === "encaisseCents"
? formatEuros(v)
: `${v} facture${v > 1 ? "s" : ""}`
}
seriesLabel={{
encaisseCents: "Encaissé",
paidCount: "Paiements",
}}
/>
}
cursor={{ fill: c.cream2, opacity: 0.6 }}
/>
<Bar
dataKey="encaisseCents"
fill={c.rubis}
radius={[3, 3, 0, 0]}
maxBarSize={28}
/>
</BarChart>
</ResponsiveContainer>
);
}

View File

@ -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 (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={enriched} margin={{ top: 10, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickFormatter={formatMonthShort}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
dy={6}
/>
<YAxis
width={28}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
tickFormatter={(v) => `${v}j`}
domain={[0, "dataMax + 5"]}
/>
<ReferenceLine
y={reference}
stroke={c.ink3}
strokeDasharray="4 4"
strokeWidth={1}
label={{
value: `Norme LME ${reference}j`,
position: "right",
fill: c.ink3,
fontSize: 10,
fontFamily: "var(--font-sans)",
}}
/>
<Tooltip
content={
<ChartTooltip
formatLabel={formatMonthLong}
formatValue={(v, key) =>
key === "dsoDays" ? `${Math.round(v)} jours` : String(v)
}
seriesLabel={{ dsoDays: "DSO moyen" }}
/>
}
cursor={{ stroke: c.ink3, strokeWidth: 1, strokeDasharray: "4 4" }}
/>
<Line
type="monotone"
dataKey="dsoDays"
stroke={c.ink}
strokeWidth={2}
dot={{ fill: c.ink, stroke: c.cream, strokeWidth: 2, r: 3 }}
activeDot={{ r: 5, stroke: c.cream, strokeWidth: 2 }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@ -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 (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 10, right: 8, left: 8, bottom: 0 }}>
<defs>
<linearGradient id="rubis-glow-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={c.rubis} stopOpacity={0.25} />
<stop offset="100%" stopColor={c.rubis} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickFormatter={formatMonthShort}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
dy={6}
/>
<YAxis hide domain={[0, "dataMax + 100"]} />
<Tooltip
content={
<ChartTooltip
formatLabel={formatMonthLong}
formatValue={(v) => formatEuros(v)}
seriesLabel={{ encaisseCents: "Encaissé" }}
/>
}
cursor={{ stroke: c.rubis, strokeWidth: 1, strokeDasharray: "4 4" }}
/>
<Area
type="monotone"
dataKey="encaisseCents"
stroke={c.rubis}
strokeWidth={2}
fill="url(#rubis-glow-gradient)"
dot={{ fill: c.rubis, stroke: c.cream, strokeWidth: 2, r: 3 }}
activeDot={{ r: 5, stroke: c.cream, strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
);
}

View File

@ -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 (
<div
className="flex items-center justify-center text-[12.5px] italic text-ink-3"
style={{ height }}
>
Aucune facture pour l'instant — créez-en une depuis l'écran factures.
</div>
);
}
return (
<div className="relative" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Tooltip
content={
<ChartTooltip
formatValue={(v, key) =>
key === "count" ? `${v} factures` : formatEuros(v)
}
seriesLabel={{ count: "Nombre", amountCents: "Montant" }}
/>
}
/>
<Pie
data={filtered}
dataKey="count"
nameKey="status"
innerRadius="62%"
outerRadius="92%"
paddingAngle={2}
stroke={c.cream}
strokeWidth={2}
isAnimationActive={false}
>
{filtered.map((entry) => (
<Cell
key={entry.status}
fill={STATUS_COLOR[entry.status] ?? c.line}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
{/* Total centré au milieu du donut */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<p className="font-display text-[28px] font-bold tabular-nums text-ink leading-none">
{total}
</p>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-ink-3 mt-1">
Factures
</p>
</div>
</div>
);
}
/**
* 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 (
<ul className="flex flex-col gap-1.5">
{filtered.map((d) => (
<li
key={d.status}
className="flex items-baseline gap-2 text-[12.5px]"
>
<span
aria-hidden="true"
className="size-2 rounded-full shrink-0 mt-1"
style={{ backgroundColor: STATUS_COLOR[d.status] }}
/>
<span className="text-ink-2 flex-1 truncate">
{STATUS_LABEL[d.status] ?? d.status}
</span>
<span className="font-display font-semibold text-ink tabular-nums">
{d.count}
</span>
<span className="text-ink-3 tabular-nums text-[11.5px]">
· {formatEuros(d.amountCents)}
</span>
</li>
))}
</ul>
);
}

View File

@ -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<string, string> = {
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<string, string> = {
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);
}

View File

@ -5,6 +5,7 @@ import {
ListChecks, ListChecks,
Users, Users,
Settings, Settings,
TrendingUp,
} from "lucide-react"; } from "lucide-react";
import { Brand } from "@/components/brand/Brand"; import { Brand } from "@/components/brand/Brand";
@ -36,6 +37,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
<NavLink to="/factures" icon={<FileText size={17} />} label="Factures" /> <NavLink to="/factures" icon={<FileText size={17} />} label="Factures" />
<NavLink to="/plans" icon={<ListChecks size={17} />} label="Plans de relance" /> <NavLink to="/plans" icon={<ListChecks size={17} />} label="Plans de relance" />
<NavLink to="/clients" icon={<Users size={17} />} label="Clients" /> <NavLink to="/clients" icon={<Users size={17} />} label="Clients" />
<NavLink to="/insights" icon={<TrendingUp size={17} />} label="Insights" />
<NavLink to="/parametres" icon={<Settings size={17} />} label="Paramètres" /> <NavLink to="/parametres" icon={<Settings size={17} />} label="Paramètres" />
</nav> </nav>

View File

@ -84,7 +84,7 @@ export function AiGenerateModal({
<DialogContent maxWidth={720}> <DialogContent maxWidth={720}>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2 border border-rubis">
<Sparkles size={16} className="text-rubis" /> Générer avec l'IA <Sparkles size={16} className="text-rubis" /> Générer avec l'IA
</span> </span>
</DialogTitle> </DialogTitle>

View File

@ -28,9 +28,20 @@ import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow"; import { Eyebrow } from "@/components/ui/Eyebrow";
import { Textarea } from "@/components/ui/Textarea"; import { Textarea } from "@/components/ui/Textarea";
import { StatusBadge } from "@/components/ui/StatusBadge"; import { StatusBadge } from "@/components/ui/StatusBadge";
import { ClientPaidChart } from "@/components/charts/ClientPaidChart";
import type { InvoiceListItem } from "@/components/factures/InvoiceTable"; import type { InvoiceListItem } from "@/components/factures/InvoiceTable";
import type { ClientWithStats } from "@/components/clients/ClientTable"; 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 & { type ClientDetail = ClientWithStats & {
invoices: InvoiceListItem[]; invoices: InvoiceListItem[];
}; };
@ -55,6 +66,12 @@ function ClientDetailPage() {
queryFn: () => api.get<ClientDetail>(`/api/v1/clients/${id}`), queryFn: () => api.get<ClientDetail>(`/api/v1/clients/${id}`),
}); });
const { data: timeseries } = useQuery({
queryKey: ["clients", id, "timeseries", 6] as const,
queryFn: () =>
api.get<ClientTimeseries>(`/api/v1/clients/${id}/timeseries?range=6`),
});
// Notes : édition locale + sauvegarde sur blur. Garde le draft local pour // Notes : édition locale + sauvegarde sur blur. Garde le draft local pour
// ne pas refetch écraser ce que l'user est en train de taper. // ne pas refetch écraser ce que l'user est en train de taper.
const [notesDraft, setNotesDraft] = useState<string>(""); const [notesDraft, setNotesDraft] = useState<string>("");
@ -190,6 +207,19 @@ function ClientDetailPage() {
/> />
</section> </section>
{/* Mini chart "encaissé sur 6 mois" pour ce client */}
<Card padding="md">
<div className="flex items-center justify-between mb-3">
<div>
<Eyebrow tone="ink">Paiements · 6 mois</Eyebrow>
<p className="mt-1 text-[12.5px] text-ink-3">
Évolution des encaissements de ce client.
</p>
</div>
</div>
<ClientPaidChart data={timeseries?.paidByMonth ?? []} />
</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]">
{/* Liste des factures du client */} {/* Liste des factures du client */}
<section> <section>

View File

@ -1,12 +1,14 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; 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 { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { formatEuros } from "@/lib/format"; import { formatEuros } from "@/lib/format";
import { Button } from "@/components/ui/Button"; 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 { RubisHero } from "@/components/dashboard/RubisHero";
import { KpiCard } from "@/components/dashboard/KpiCard"; import { KpiCard } from "@/components/dashboard/KpiCard";
import { import {
@ -17,6 +19,27 @@ import {
TopLatePayers, TopLatePayers,
type LatePayer, type LatePayer,
} from "@/components/dashboard/TopLatePayers"; } 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 = { type DashboardKpis = {
rubisCount: number; rubisCount: number;
@ -61,6 +84,11 @@ function DashboardPage() {
queryFn: () => api.get<LatePayer[]>("/api/v1/dashboard/top-late"), queryFn: () => api.get<LatePayer[]>("/api/v1/dashboard/top-late"),
}); });
const { data: timeseries } = useQuery({
queryKey: ["dashboard", "timeseries", 6] as const,
queryFn: () => api.get<Timeseries>("/api/v1/dashboard/timeseries?range=6"),
});
return ( return (
<div className="flex flex-col gap-6 lg:gap-7"> <div className="flex flex-col gap-6 lg:gap-7">
{/* Actions mobile : visibles seulement sur mobile (le topbar mobile montre la marque). */} {/* Actions mobile : visibles seulement sur mobile (le topbar mobile montre la marque). */}
@ -127,11 +155,73 @@ function DashboardPage() {
/> />
</section> </section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1.2fr_1fr] lg:gap-5"> {/* Charts encaissé + DSO côte à côte, puis pipeline en pleine largeur.
<ActivityFeed events={activity} /> Cliquable vers /insights pour la version avec range selector. */}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr] lg:gap-5">
<Card padding="md" className="flex flex-col">
<div className="flex items-center justify-between mb-3">
<div>
<Eyebrow tone="ink">Encaissé · 6 mois</Eyebrow>
<p className="mt-1 text-[12.5px] text-ink-3">
Argent récupéré chaque mois.
</p>
</div>
<Link
to="/insights"
className="text-[12px] text-rubis hover:underline underline-offset-4"
>
Détails
</Link>
</div>
<EncaisseChart data={timeseries?.paidByMonth ?? []} />
</Card>
<Card padding="md" className="flex flex-col">
<div className="flex items-center justify-between mb-3">
<div>
<Eyebrow tone="ink">DSO · 6 mois</Eyebrow>
<p className="mt-1 text-[12.5px] text-ink-3">
Délai moyen entre émission et paiement.
</p>
</div>
<Link
to="/insights"
className="text-[12px] text-rubis hover:underline underline-offset-4"
>
Détails
</Link>
</div>
<DsoTrendChart data={timeseries?.paidByMonth ?? []} />
</Card>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr] lg:gap-5">
<Card padding="md">
<div className="flex items-center justify-between mb-3">
<Eyebrow tone="ink">Pipeline factures</Eyebrow>
<Link
to="/factures"
className="text-[12px] text-rubis hover:underline underline-offset-4"
>
Voir <ArrowRight size={11} className="inline" />
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-4 items-center">
<PipelineChart
data={timeseries?.pipelineByStatus ?? []}
height={200}
/>
<PipelineLegend data={timeseries?.pipelineByStatus ?? []} />
</div>
</Card>
<TopLatePayers payers={latePayers} /> <TopLatePayers payers={latePayers} />
</section> </section>
<section>
<ActivityFeed events={activity} />
</section>
{/* Petite signature visuelle en bas — discret, juste pour aérer. */} {/* Petite signature visuelle en bas — discret, juste pour aérer. */}
<p className="mt-2 hidden lg:flex items-center gap-1.5 text-[11px] text-ink-3"> <p className="mt-2 hidden lg:flex items-center gap-1.5 text-[11px] text-ink-3">
<ArrowDownRight size={12} aria-hidden="true" /> <ArrowDownRight size={12} aria-hidden="true" />

View File

@ -0,0 +1,190 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/lib/api";
import { formatEuros } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { EncaisseChart } from "@/components/charts/EncaisseChart";
import { DsoTrendChart } from "@/components/charts/DsoTrendChart";
import {
PipelineChart,
PipelineLegend,
} from "@/components/charts/PipelineChart";
type Range = 3 | 6 | 12;
type Timeseries = {
range: Range;
paidByMonth: Array<{
month: string;
encaisseCents: number;
paidCount: number;
dsoDays: number;
}>;
pipelineByStatus: Array<{
status: string;
count: number;
amountCents: number;
}>;
};
export const Route = createFileRoute("/_app/insights")({
component: InsightsPage,
});
const RANGES: { value: Range; label: string }[] = [
{ value: 3, label: "3 mois" },
{ value: 6, label: "6 mois" },
{ value: 12, label: "12 mois" },
];
function InsightsPage() {
const [range, setRange] = useState<Range>(6);
const { data: ts } = useQuery({
queryKey: ["dashboard", "timeseries", range] as const,
queryFn: () =>
api.get<Timeseries>(`/api/v1/dashboard/timeseries?range=${range}`),
});
const totalEncaisse =
ts?.paidByMonth.reduce((s, m) => s + m.encaisseCents, 0) ?? 0;
const totalPaid =
ts?.paidByMonth.reduce((s, m) => s + m.paidCount, 0) ?? 0;
const dsoMoyen = (() => {
if (!ts) return 0;
const months = ts.paidByMonth.filter((m) => m.paidCount > 0);
if (months.length === 0) return 0;
return Math.round(
months.reduce((s, m) => s + m.dsoDays, 0) / months.length,
);
})();
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<Eyebrow>Insights</Eyebrow>
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Comprendre vos <em className="text-rubis">flux</em>
</h1>
<p className="mt-1.5 text-[14px] text-ink-3 max-w-xl leading-relaxed">
Vue d'ensemble du portefeuille et des encaissements. Filtre la
période pour comparer.
</p>
</div>
<RangePicker value={range} onChange={setRange} />
</header>
{/* Récap chiffré au-dessus des charts pour ancrer la lecture. */}
<section
aria-label="Synthèse de la période"
className="grid grid-cols-2 gap-3 lg:grid-cols-3 lg:gap-4"
>
<SummaryCard
label={`Encaissé ${range} mois`}
value={formatEuros(totalEncaisse)}
/>
<SummaryCard label="Factures payées" value={String(totalPaid)} />
<SummaryCard label="DSO moyen" value={dsoMoyen > 0 ? `${dsoMoyen} j` : "—"} />
</section>
{/* Encaissé — pleine largeur, chart le plus important */}
<Card padding="md">
<div className="mb-4">
<Eyebrow tone="ink">Encaissement mensuel</Eyebrow>
<p className="mt-1 text-[13px] text-ink-3">
Total facturé encaissé chaque mois sur la période sélectionnée.
</p>
</div>
<EncaisseChart data={ts?.paidByMonth ?? []} height={300} />
</Card>
{/* DSO + Pipeline côte à côte */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-5">
<Card padding="md">
<div className="mb-4">
<Eyebrow tone="ink">Délai de paiement (DSO)</Eyebrow>
<p className="mt-1 text-[13px] text-ink-3">
Jours moyens entre l'émission et le paiement. La référence
à 30 j est la norme LME pour le B2B.
</p>
</div>
<DsoTrendChart data={ts?.paidByMonth ?? []} height={260} />
</Card>
<Card padding="md">
<div className="mb-4">
<Eyebrow tone="ink">Pipeline factures</Eyebrow>
<p className="mt-1 text-[13px] text-ink-3">
Répartition par statut sur l'ensemble du portefeuille.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-[220px_1fr] gap-4 items-center">
<PipelineChart
data={ts?.pipelineByStatus ?? []}
height={220}
/>
<PipelineLegend data={ts?.pipelineByStatus ?? []} />
</div>
</Card>
</div>
</div>
);
}
function RangePicker({
value,
onChange,
}: {
value: Range;
onChange: (r: Range) => void;
}) {
return (
<div
role="radiogroup"
aria-label="Période"
className="inline-flex rounded-default border border-line bg-white p-0.5 self-start sm:self-end"
>
{RANGES.map((r) => {
const active = r.value === value;
return (
<button
key={r.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(r.value)}
className={cn(
"px-3 h-8 rounded-default text-[12.5px] font-medium tabular-nums",
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
active
? "bg-rubis text-white"
: "text-ink-2 hover:bg-cream-2",
)}
>
{r.label}
</button>
);
})}
</div>
);
}
function SummaryCard({ label, value }: { label: string; value: string }) {
return (
<Card padding="md">
<p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-ink-3">
{label}
</p>
<p className="mt-2 font-display text-[24px] font-bold leading-none tracking-[-0.018em] tabular-nums text-ink">
{value}
</p>
</Card>
);
}

View File

@ -628,7 +628,7 @@ function StepMessages({
Étape {selectedIdx + 1} · J{selected.offsetDays >= 0 ? "+" : ""} Étape {selectedIdx + 1} · J{selected.offsetDays >= 0 ? "+" : ""}
{selected.offsetDays} {selected.offsetDays}
</p> </p>
<Button size="sm" variant="ghost" onClick={() => setAiOpen(true)}> <Button size="sm" variant="secondary" onClick={() => setAiOpen(true)}>
<Sparkles size={13} className="text-rubis" /> Générer avec l'IA <Sparkles size={13} className="text-rubis" /> Générer avec l'IA
</Button> </Button>
</div> </div>

288
pnpm-lock.yaml generated
View File

@ -222,6 +222,9 @@ importers:
react-dom: react-dom:
specifier: ^19.2.5 specifier: ^19.2.5
version: 19.2.5(react@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: sonner:
specifier: ^1.7.4 specifier: ^1.7.4
version: 1.7.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) 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': '@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} 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': '@rolldown/binding-android-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -2387,6 +2401,9 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 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': '@stylistic/eslint-plugin@5.10.0':
resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -2786,6 +2803,33 @@ packages:
'@types/cookiejar@2.1.5': '@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} 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': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@ -2845,6 +2889,9 @@ packages:
'@types/superagent@8.1.9': '@types/superagent@8.1.9':
resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} 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': '@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
@ -3319,6 +3366,50 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 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: data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -3350,6 +3441,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0: decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@ -3503,6 +3597,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-toolkit@1.46.1:
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
esbuild@0.27.7: esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -4005,6 +4102,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'} 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: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -4038,6 +4141,10 @@ packages:
ini@1.3.8: ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} 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: interpret@2.2.0:
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -4856,6 +4963,18 @@ packages:
react-is@18.3.1: react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} 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: react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -4914,6 +5033,14 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'} 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: rechoir@0.8.0:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'} engines: {node: '>= 10.13.0'}
@ -4930,6 +5057,14 @@ packages:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'} 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: reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@ -4945,6 +5080,9 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -5302,6 +5440,9 @@ packages:
timekeeper@2.3.1: timekeeper@2.3.1:
resolution: {integrity: sha512-LeQRS7/4JcC0PgdSFnfUiStQEdiuySlCj/5SJ18D+T1n9BoY7PxKFfCwLulpHXoLUFr67HxBddQdEX47lDGx1g==} 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: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -5520,6 +5661,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-node@3.2.4: vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@ -7598,6 +7742,18 @@ snapshots:
'@radix-ui/rect@1.1.1': {} '@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': '@rolldown/binding-android-arm64@1.0.0-rc.17':
optional: true optional: true
@ -8073,6 +8229,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@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))': '@stylistic/eslint-plugin@5.10.0(eslint@10.3.0(jiti@2.7.0))':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) '@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/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/deep-eql@4.0.2': {}
'@types/esrecurse@4.3.1': {} '@types/esrecurse@4.3.1': {}
@ -8502,6 +8684,8 @@ snapshots:
'@types/node': 25.6.0 '@types/node': 25.6.0
form-data: 4.0.5 form-data: 4.0.5
'@types/use-sync-external-store@0.0.6': {}
'@types/validator@13.15.10': {} '@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)': '@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: {} 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: data-urls@5.0.0:
dependencies: dependencies:
whatwg-mimetype: 4.0.0 whatwg-mimetype: 4.0.0
@ -9011,6 +9233,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {} decimal.js@10.6.0: {}
decompress-response@6.0.0: decompress-response@6.0.0:
@ -9122,6 +9346,8 @@ snapshots:
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
hasown: 2.0.3 hasown: 2.0.3
es-toolkit@1.46.1: {}
esbuild@0.27.7: esbuild@0.27.7:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7 '@esbuild/aix-ppc64': 0.27.7
@ -9663,6 +9889,10 @@ snapshots:
ignore@7.0.5: {} ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.7: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@ -9684,6 +9914,8 @@ snapshots:
ini@1.3.8: {} ini@1.3.8: {}
internmap@2.0.3: {}
interpret@2.2.0: {} interpret@2.2.0: {}
ioredis@5.10.1: ioredis@5.10.1:
@ -10452,6 +10684,15 @@ snapshots:
react-is@18.3.1: {} 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): react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5):
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
@ -10509,6 +10750,26 @@ snapshots:
real-require@0.2.0: {} 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: rechoir@0.8.0:
dependencies: dependencies:
resolve: 1.22.12 resolve: 1.22.12
@ -10524,6 +10785,12 @@ snapshots:
dependencies: dependencies:
redis-errors: 1.2.0 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: {} reflect-metadata@0.2.2: {}
regexp-tree@0.1.27: {} regexp-tree@0.1.27: {}
@ -10534,6 +10801,8 @@ snapshots:
require-directory@2.1.1: {} require-directory@2.1.1: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-from@5.0.0: {} resolve-from@5.0.0: {}
@ -10907,6 +11176,8 @@ snapshots:
timekeeper@2.3.1: {} timekeeper@2.3.1: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
@ -11094,6 +11365,23 @@ snapshots:
vary@1.1.2: {} 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): 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: dependencies:
cac: 6.7.14 cac: 6.7.14