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:
parent
32fcb02108
commit
2d3766cc3d
@ -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<string, number> = {
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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())
|
||||
}
|
||||
|
||||
@ -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 <user-email>')
|
||||
this.exitCode = 1
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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"
|
||||
|
||||
68
apps/web/src/components/charts/ChartTooltip.tsx
Normal file
68
apps/web/src/components/charts/ChartTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
apps/web/src/components/charts/ClientPaidChart.tsx
Normal file
91
apps/web/src/components/charts/ClientPaidChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/components/charts/DsoTrendChart.tsx
Normal file
105
apps/web/src/components/charts/DsoTrendChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/components/charts/EncaisseChart.tsx
Normal file
76
apps/web/src/components/charts/EncaisseChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/web/src/components/charts/PipelineChart.tsx
Normal file
120
apps/web/src/components/charts/PipelineChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
apps/web/src/components/charts/theme.ts
Normal file
81
apps/web/src/components/charts/theme.ts
Normal 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);
|
||||
}
|
||||
@ -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 })
|
||||
<NavLink to="/factures" icon={<FileText size={17} />} label="Factures" />
|
||||
<NavLink to="/plans" icon={<ListChecks size={17} />} label="Plans de relance" />
|
||||
<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" />
|
||||
</nav>
|
||||
|
||||
|
||||
@ -84,7 +84,7 @@ export function AiGenerateModal({
|
||||
<DialogContent maxWidth={720}>
|
||||
<DialogHeader>
|
||||
<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
|
||||
</span>
|
||||
</DialogTitle>
|
||||
|
||||
@ -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<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
|
||||
// ne pas refetch écraser ce que l'user est en train de taper.
|
||||
const [notesDraft, setNotesDraft] = useState<string>("");
|
||||
@ -190,6 +207,19 @@ function ClientDetailPage() {
|
||||
/>
|
||||
</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]">
|
||||
{/* Liste des factures du client */}
|
||||
<section>
|
||||
|
||||
@ -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<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 (
|
||||
<div className="flex flex-col gap-6 lg:gap-7">
|
||||
{/* Actions mobile : visibles seulement sur mobile (le topbar mobile montre la marque). */}
|
||||
@ -127,11 +155,73 @@ function DashboardPage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1.2fr_1fr] lg:gap-5">
|
||||
<ActivityFeed events={activity} />
|
||||
{/* Charts — encaissé + DSO côte à côte, puis pipeline en pleine largeur.
|
||||
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} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<ActivityFeed events={activity} />
|
||||
</section>
|
||||
|
||||
{/* 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">
|
||||
<ArrowDownRight size={12} aria-hidden="true" />
|
||||
|
||||
190
apps/web/src/routes/_app/insights.tsx
Normal file
190
apps/web/src/routes/_app/insights.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -628,7 +628,7 @@ function StepMessages({
|
||||
Étape {selectedIdx + 1} · J{selected.offsetDays >= 0 ? "+" : ""}
|
||||
{selected.offsetDays}
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
288
pnpm-lock.yaml
generated
288
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user