diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cabec..7b68915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ Toutes les évolutions notables de **Dalibi** sont consignées ici. Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le versionnage respecte [SemVer](https://semver.org/lang/fr/). +## [1.0.1] — 2026-09-02 + +### Corrigé +- **Couleur des catégories dans les graphiques** : la couleur suivait la *position* + dans le tableau et non la catégorie. Lorsqu'une catégorie vide était filtrée, les + suivantes changeaient de couleur — les filles pouvaient hériter du bleu des garçons, + « Très bien » perdre son vert. + +### Accessibilité +- **Palette revue et validée** (bande de luminosité, chroma, séparation en vision + déficiente, contraste) dans les modes clair *et* sombre. L'ancien couple bleu/violet + était indistinguable en deutéranopie. +- **Légendes ajoutées** aux camemberts qui n'en avaient pas : l'identité ne repose plus + sur la seule couleur. +- Les répartitions à plus de trois catégories (modes de paiement, régions) passent en + **barres étiquetées** ; les mentions, données ordonnées, en **rampe séquentielle**. +- **Mode sombre** : couleurs d'axes et de grille désormais adaptées au thème, au lieu de + valeurs claires codées en dur (la grille était quasi invisible sur fond sombre). +- L'animation d'entrée des camemberts est retirée : elle laissait le graphique **vide** + lorsque les images d'animation sont ralenties (onglet en arrière-plan, économie + d'énergie, `prefers-reduced-motion`). ## [1.0.0] — 2026-09-02 Première version stable. Dalibi couvre le cycle complet de gestion d'un établissement @@ -59,4 +80,5 @@ scolaire, de l'inscription de l'élève au bulletin, en passant par l'écolage e Laravel 12 (PHP 8.3+), React 19 + TypeScript via Inertia.js, Tailwind CSS 4, PostgreSQL, identifiants UUID. **502 tests** automatisés. +[1.0.1]: https://github.com/wearedalibi/dalibi/releases/tag/v1.0.1 [1.0.0]: https://github.com/wearedalibi/dalibi/releases/tag/v1.0.0 diff --git a/package.json b/package.json index 27a7f8a..02c9c05 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://www.schemastore.org/package.json", "private": true, - "version": "1.0.0", + "version": "1.0.1", "type": "module", "scripts": { "build": "vite build", diff --git a/resources/js/lib/chart-theme.ts b/resources/js/lib/chart-theme.ts new file mode 100644 index 0000000..f1a41c6 --- /dev/null +++ b/resources/js/lib/chart-theme.ts @@ -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(() => { + 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]; +} diff --git a/resources/js/pages/Comptabilite/Accounting/Dashboard.tsx b/resources/js/pages/Comptabilite/Accounting/Dashboard.tsx index 45fba31..2b4ae1e 100644 --- a/resources/js/pages/Comptabilite/Accounting/Dashboard.tsx +++ b/resources/js/pages/Comptabilite/Accounting/Dashboard.tsx @@ -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 */ @@ -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) { + const theme = useChartTheme(); const fmt = useMoney(); const [yearId, setYearId] = useState(filters.academic_year_id ?? ''); const [classId, setClassId] = useState(filters.class_id ?? ''); @@ -332,16 +334,16 @@ export default function AccountingDashboard({
- - + + (v >= 1000 ? `${Math.round(v / 1000)}k` : String(v))} /> [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} /> @@ -350,11 +352,11 @@ export default function AccountingDashboard({ wrapperStyle={{ fontSize: 12 }} /> {totalExpected > 0 && ( - + )} - - + +
diff --git a/resources/js/pages/Statistiques/Index.tsx b/resources/js/pages/Statistiques/Index.tsx index 1029fc6..5771936 100644 --- a/resources/js/pages/Statistiques/Index.tsx +++ b/resources/js/pages/Statistiques/Index.tsx @@ -1,15 +1,16 @@ import { Head, router } from '@inertiajs/react'; -import { useMoney } from '@/helpers/money'; import { BarChart3, Download, FileSpreadsheet, GraduationCap, Layers, MapPin, PieChart as PieIcon, School, TrendingUp, UserCheck, Users, Wallet } from 'lucide-react'; import { useState } from 'react'; import { - Area, AreaChart, Bar, BarChart, Cell, Legend, Line, LineChart, Pie, PieChart, + Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip as RTooltip, XAxis, YAxis, } from 'recharts'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useMoney } from '@/helpers/money'; import { route } from '@/helpers/route'; import AppLayout from '@/layouts/app-layout'; +import { useChartTheme } from '@/lib/chart-theme'; import type { BreadcrumbItem } from '@/types'; /* ---------------- Types ---------------- */ @@ -78,7 +79,7 @@ interface Props { /* ---------------- Helpers ---------------- */ const methodLabel: Record = { CASH: 'Espèces', MOBILE_MONEY: 'Mobile Money', BANK_TRANSFER: 'Virement', CHEQUE: 'Chèque' }; -const BLUE = '#2563eb', PINK = '#db2777', GREEN = '#16a34a', ORANGE = '#ea580c', VIOLET = '#7c3aed', SLATE = '#94a3b8'; + const breadcrumbs: BreadcrumbItem[] = [{ title: 'Statistiques', href: '/statistiques' }]; @@ -112,6 +113,24 @@ function Card({ title, icon, children }: { title: string; icon?: React.ReactNode type Tab = 'effectifs' | 'finances' | 'reussite' | 'encadrement' | 'assiduite' | 'comparaisons' | 'geographie'; export default function StatisticsIndex({ filters, academicYears, classes, enrollment, finance, success, resources, attendance, trends, geography }: Readonly) { + const theme = useChartTheme(); + const [BLUE, ORANGE, GREEN, VIOLET] = theme.series; + + // La couleur est portée par la catégorie elle-même : filtrer une catégorie + // vide ne doit jamais repeindre les autres. + const genderData = [ + { name: 'Garçons', value: enrollment.gender.male, color: BLUE }, + { name: 'Filles', value: enrollment.gender.female, color: ORANGE }, + { name: 'Autre', value: enrollment.gender.other, color: GREEN }, + ].filter((d) => d.value > 0); + + // Donnée ordonnée (Passable → Très bien) : rampe séquentielle, pas des teintes sans rapport. + const mentionData = [ + { name: 'Passable', value: success.mentions.passable }, + { name: 'Assez bien', value: success.mentions.assez_bien }, + { name: 'Bien', value: success.mentions.bien }, + { name: 'Très bien', value: success.mentions.tres_bien }, + ].map((d, i) => ({ ...d, color: theme.sequential[i] })).filter((d) => d.value > 0); const money = useMoney(); const [tab, setTab] = useState('effectifs'); @@ -197,22 +216,23 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol
}> - - + + - +
}> - d.value > 0)}> - {[BLUE, PINK, SLATE].map((c) => )} + + {genderData.map((d) => )} - + + @@ -221,8 +241,8 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol }> - - + + @@ -266,22 +286,27 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol - - v >= 1000 ? `${Math.round(v / 1000)}k` : `${v}`} /> + + v >= 1000 ? `${Math.round(v / 1000)}k` : `${v}`} /> money(Number(v))} /> }> + {/* Comparer des montants par catégorie est une magnitude, pas une identité : + des barres étiquetées en une seule teinte se lisent mieux qu'un camembert, + et l'identité ne repose plus sur la couleur. */} - - ({ ...m, method: methodLabel[m.method] ?? m.method }))}> - {finance.by_method.map((_, i) => )} - - money(Number(v))} /> - + ({ ...m, method: methodLabel[m.method] ?? m.method }))} + margin={{ top: 4, right: 16, left: 8, bottom: 4 }}> + + (Number(v) >= 1000 ? `${Math.round(Number(v) / 1000)}k` : String(v))} /> + + money(Number(v))} contentStyle={theme.tooltip.contentStyle} itemStyle={theme.tooltip.itemStyle} /> + + @@ -315,11 +340,11 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol }> - d.value > 0)}> - {[SLATE, BLUE, VIOLET, GREEN].map((c) => )} + + {mentionData.map((d) => )} - + + @@ -355,8 +380,8 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol }> - - + + {resources.class_sizes.map((c, i) => resources.threshold ? ORANGE : BLUE} />)} @@ -398,8 +423,8 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol }> - - + + @@ -442,8 +467,8 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol }> - - + + @@ -452,8 +477,8 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol }> - - + + @@ -496,21 +521,23 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol ) : (
}> + {/* Six régions dépassent ce qu'une palette catégorielle peut distinguer + de façon sûre : on passe en barres étiquetées, une seule teinte. */} - - - {geography.by_region.map((_, i) => )} - - - - + + + + + + +
}> - - + + diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 7b9446f..5a0a172 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -1,5 +1,4 @@ import { Head, Link, router } from '@inertiajs/react'; -import { useMoney } from '@/helpers/money'; import { Users, Banknote, TrendingUp, TrendingDown, Wallet, CheckCircle2, AlertCircle, XCircle, AlertTriangle, BookOpen, ClipboardList, ArrowRight, CalendarDays, GraduationCap, UserCheck, ShieldCheck, FileBadge, LayoutGrid, User, Layers } from 'lucide-react'; import { Area, AreaChart, Bar, BarChart, Cell, Pie, PieChart, @@ -7,8 +6,10 @@ import { } from 'recharts'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useMoney } from '@/helpers/money'; import { route } from '@/helpers/route'; import AppLayout from '@/layouts/app-layout'; +import { useChartTheme } from '@/lib/chart-theme'; import type { BreadcrumbItem } from '@/types'; /* ------------------------------------------------------------------ */ @@ -265,6 +266,7 @@ function SectionCard({ title, icon, count, children, action }: { const breadcrumbs: BreadcrumbItem[] = [{ title: 'Tableau de bord', href: '/dashboard' }]; export default function Dashboard({ activeYear, selectedYearId, selectedYear, academicYears, userRole, financial, enrollments, teaching, academic }: Readonly) { + const theme = useChartTheme(); const fmt = useMoney(); const changeYear = (id: string) => { @@ -275,9 +277,9 @@ export default function Dashboard({ activeYear, selectedYearId, selectedYear, ac const collectedPct = pct(Number(stats?.total_paid ?? 0), Number(stats?.total_amount ?? 0)); const invoiceStatusData = stats ? [ - { name: 'Soldé', value: Number(stats.paid_count ?? 0), color: '#22c55e' }, - { name: 'Partiel', value: Number(stats.partial_count ?? 0), color: '#f97316' }, - { name: 'Non payé', value: Number(stats.issued_count ?? 0), color: '#ef4444' }, + { name: 'Soldé', value: Number(stats.paid_count ?? 0), color: theme.status.good }, + { name: 'Partiel', value: Number(stats.partial_count ?? 0), color: theme.status.warning }, + { name: 'Non payé', value: Number(stats.issued_count ?? 0), color: theme.status.critical }, ].filter(d => d.value > 0) : []; const isFinancial = !!financial; @@ -562,15 +564,15 @@ export default function Dashboard({ activeYear, selectedYearId, selectedYear, ac margin={{ top: 5, right: 10, left: 0, bottom: 0 }}> - - + + - - + v >= 1000 ? `${Math.round(v / 1000)}k` : `${v}`} /> [fmt(Number(v)), 'Encaissé']} labelFormatter={() => ''} /> - + )} @@ -658,7 +660,7 @@ export default function Dashboard({ activeYear, selectedYearId, selectedYear, ac
- {invoiceStatusData.map(d => )} @@ -690,10 +692,10 @@ export default function Dashboard({ activeYear, selectedYearId, selectedYear, ac ({ name: c.class_name, total: Number(c.total) }))} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}> - - + + [`${v}`, 'Élèves']} /> - +