Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions resources/js/lib/chart-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useMemo } from 'react';
import { useAppearance } from '@/hooks/use-appearance';

/**
* Thème des graphiques.
*
* Les couleurs de séries sont **validées** (bande de luminosité, plancher de
* chroma, séparation en vision déficiente, plancher en vision normale, contraste
* sur la surface) dans les deux modes. Ne pas les modifier sans revalider :
* bleu et violet, par exemple, sont indistinguables en deutéranopie (ΔE 0.4).
*
* ⚠️ Trois teintes au maximum lorsque toutes les paires sont comparables entre
* elles (camembert, nuage de points) : au-delà, aucun jeu ne tient dans les deux
* modes. Pour davantage de catégories, utiliser des **barres étiquetées en une
* seule teinte** — le travail est alors une magnitude, pas une identité.
*/
const SERIES = {
light: ['#2a78d6', '#eb6834', '#1baf7a', '#4a3aa7'],
dark: ['#3987e5', '#d95926', '#199e70', '#9085e9'],
} as const;

/** Rampe séquentielle (magnitude / donnée ordonnée) : une seule teinte, clair → foncé. */
const SEQUENTIAL = {
light: ['#bfdbfe', '#60a5fa', '#2a78d6', '#1e3a8a'],
dark: ['#1e3a8a', '#1d4ed8', '#3987e5', '#93c5fd'],
} as const;

/** Couleurs d'état — réservées, jamais réutilisées comme teinte de série. */
const STATUS = {
light: { good: '#1baf7a', warning: '#eda100', critical: '#e34948' },
dark: { good: '#199e70', warning: '#c98500', critical: '#e66767' },
} as const;

/** Habillage : grille et axes restent en retrait, jamais au premier plan. */
const CHROME = {
light: { grid: '#e5e7eb', axis: '#6b7280', tick: '#9ca3af', surface: '#ffffff', border: '#e5e7eb' },
dark: { grid: '#374151', axis: '#9ca3af', tick: '#9ca3af', surface: '#1f2937', border: '#374151' },
} as const;

export type ChartTheme = {
mode: 'light' | 'dark';
/** Teintes catégorielles, dans un ordre fixe — ne jamais cycler. */
series: readonly string[];
/** Première teinte : le défaut d'une série unique. */
primary: string;
sequential: readonly string[];
status: { good: string; warning: string; critical: string };
grid: string;
axis: string;
tick: string;
surface: string;
border: string;
/** Style de l'infobulle, accordé au mode. */
tooltip: { contentStyle: React.CSSProperties; itemStyle: React.CSSProperties };
};

export function useChartTheme(): ChartTheme {
const { resolvedAppearance } = useAppearance();

return useMemo<ChartTheme>(() => {
const mode = resolvedAppearance;
const chrome = CHROME[mode];

return {
mode,
series: SERIES[mode],
primary: SERIES[mode][0],
sequential: SEQUENTIAL[mode],
status: STATUS[mode],
...chrome,
tooltip: {
contentStyle: {
borderRadius: 12,
border: `1px solid ${chrome.border}`,
background: chrome.surface,
fontSize: 13,
color: mode === 'dark' ? '#f3f4f6' : '#111827',
},
itemStyle: { color: mode === 'dark' ? '#f3f4f6' : '#111827' },
},
};
}, [resolvedAppearance]);
}

/**
* Couleur d'une catégorie, dérivée de son **identité** et non de sa position.
*
* Indispensable quand la liste est filtrée ou triée : sans cela, retirer une
* catégorie vide décale toutes les couleurs (les filles héritent du bleu des
* garçons, « Très bien » perd son vert…).
*/
export function colorFor(key: string, keys: readonly string[], series: readonly string[]): string {
const index = keys.indexOf(key);

return series[index === -1 ? 0 : index % series.length];
}
26 changes: 14 additions & 12 deletions resources/js/pages/Comptabilite/Accounting/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { Head, router } from '@inertiajs/react';
import { useMoney } from '@/helpers/money';
import {
TrendingUp, TrendingDown, Users, AlertTriangle,
CheckCircle2, Clock, AlertCircle, ChevronDown,
Eye, BookOpen, Banknote, Filter, XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { route } from '@/helpers/route';
import AppLayout from '@/layouts/app-layout';
import { useState } from 'react';
import {
Bar, CartesianGrid, ComposedChart, Legend, Line,
ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { Button } from '@/components/ui/button';
import { useMoney } from '@/helpers/money';
import { route } from '@/helpers/route';
import AppLayout from '@/layouts/app-layout';
import { useChartTheme } from '@/lib/chart-theme';

/* ------------------------------------------------------------------ */
/* Types */
Expand Down Expand Up @@ -166,6 +167,7 @@ function SectionHeader({ icon, title, count }: { icon: React.ReactNode; title: s
export default function AccountingDashboard({
academicYears, classrooms, filters, globalStats, monthlyPayments, byClass, studentsUnpaid, studentsUnpaidTotal,
}: Readonly<DashboardProps>) {
const theme = useChartTheme();
const fmt = useMoney();
const [yearId, setYearId] = useState(filters.academic_year_id ?? '');
const [classId, setClassId] = useState(filters.class_id ?? '');
Expand Down Expand Up @@ -332,16 +334,16 @@ export default function AccountingDashboard({
<div className="h-72 mt-4 -ml-2">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 10, right: 12, left: 4, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12, fill: '#6b7280' }} axisLine={false} tickLine={false} />
<CartesianGrid strokeDasharray="3 3" stroke={theme.grid} vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12, fill: theme.axis }} axisLine={false} tickLine={false} />
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }} axisLine={false} tickLine={false} width={70}
tick={{ fontSize: 11, fill: theme.tick }} axisLine={false} tickLine={false} width={70}
tickFormatter={(v: number) => (v >= 1000 ? `${Math.round(v / 1000)}k` : String(v))}
/>
<Tooltip
formatter={(value, name) => [fmt(Number(value)), name === 'encaisse' ? 'Encaissé du mois' : 'Cumul encaissé']}
labelFormatter={(_label, payload) => payload?.[0]?.payload?.fullLabel ?? ''}
contentStyle={{ borderRadius: 12, border: '1px solid #e5e7eb', fontSize: 13 }}
contentStyle={theme.tooltip.contentStyle} itemStyle={theme.tooltip.itemStyle}
/>
<Legend
formatter={(value: string) =>
Expand All @@ -350,11 +352,11 @@ export default function AccountingDashboard({
wrapperStyle={{ fontSize: 12 }}
/>
{totalExpected > 0 && (
<ReferenceLine y={totalExpected} stroke="#f59e0b" strokeDasharray="5 5"
label={{ value: 'Attendu', position: 'right', fill: '#b45309', fontSize: 11 }} />
<ReferenceLine y={totalExpected} stroke={theme.status.warning} strokeDasharray="5 5"
label={{ value: 'Attendu', position: 'right', fill: theme.status.warning, fontSize: 11 }} />
)}
<Bar dataKey="encaisse" name="encaisse" fill="#3b82f6" radius={[6, 6, 0, 0]} maxBarSize={44} />
<Line type="monotone" dataKey="cumul" name="cumul" stroke="#10b981" strokeWidth={2.5} dot={{ r: 3 }} />
<Bar dataKey="encaisse" name="encaisse" fill={theme.primary} radius={[6, 6, 0, 0]} maxBarSize={44} />
<Line type="monotone" dataKey="cumul" name="cumul" stroke={theme.series[2]} strokeWidth={2.5} dot={{ r: 3 }} />
</ComposedChart>
</ResponsiveContainer>
</div>
Expand Down
Loading
Loading