From ec6e9f9de44f3b205b309cb529392f196612878c Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Wed, 21 Oct 2026 10:56:10 +0000 Subject: [PATCH 01/68] feat(i18n): support multilingue FR/EN (infra + coquille) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Met en place l'internationalisation : - Fichiers de langue Laravel (lang/fr, lang/en : menu, common, settings) partagés à React via Inertia (langue active + repli FR). - Hook t() maison (resources/js/helpers/i18n.ts). - Langue liée au profil (colonne users.locale) + middleware SetLocale. - Sélecteur de langue dans Paramètres → Apparence (PATCH /settings/language). - Catalogue des langues (App\Constants\Locales), défaut FR. - Coquille traduite : menu latéral et fil d'ariane. Tests : bascule de langue (persistance, langue invalide rejetée, partage). --- app/Constants/Locales.php | 60 ++++++++ .../Settings/LanguageController.php | 28 ++++ app/Http/Middleware/HandleInertiaRequests.php | 40 ++++++ app/Http/Middleware/SetLocale.php | 29 ++++ app/Models/User.php | 1 + bootstrap/app.php | 2 + config/app.php | 4 +- ...10_21_000001_add_locale_to_users_table.php | 23 +++ lang/en/common.php | 47 ++++++ lang/en/menu.php | 82 +++++++++++ lang/en/settings.php | 29 ++++ lang/fr/common.php | 47 ++++++ lang/fr/menu.php | 82 +++++++++++ lang/fr/settings.php | 29 ++++ .../js/components/app-sidebar-header.tsx | 6 +- resources/js/components/nav-main.tsx | 12 +- resources/js/helpers/i18n.ts | 52 +++++++ resources/js/pages/settings/appearance.tsx | 62 ++++++-- resources/js/types/menu.ts | 136 +++++++++--------- routes/settings.php | 4 + tests/Feature/LocaleTest.php | 53 +++++++ 21 files changed, 738 insertions(+), 90 deletions(-) create mode 100644 app/Constants/Locales.php create mode 100644 app/Http/Controllers/Settings/LanguageController.php create mode 100644 app/Http/Middleware/SetLocale.php create mode 100644 database/migrations/2026_10_21_000001_add_locale_to_users_table.php create mode 100644 lang/en/common.php create mode 100644 lang/en/menu.php create mode 100644 lang/en/settings.php create mode 100644 lang/fr/common.php create mode 100644 lang/fr/menu.php create mode 100644 lang/fr/settings.php create mode 100644 resources/js/helpers/i18n.ts create mode 100644 tests/Feature/LocaleTest.php diff --git a/app/Constants/Locales.php b/app/Constants/Locales.php new file mode 100644 index 00000000..20786f3b --- /dev/null +++ b/app/Constants/Locales.php @@ -0,0 +1,60 @@ + + */ + public static function all(): array + { + return [ + 'fr' => ['label' => 'Français', 'flag' => '🇫🇷'], + 'en' => ['label' => 'English', 'flag' => '🇬🇧'], + ]; + } + + /** Codes valides (pour la validation). */ + public static function codes(): array + { + return array_keys(self::all()); + } + + /** Le code est-il une langue prise en charge ? */ + public static function isValid(?string $code): bool + { + return $code !== null && array_key_exists($code, self::all()); + } + + /** Libellé lisible d'un code (repli sur le code). */ + public static function label(?string $code): string + { + $code = $code ?: self::DEFAULT; + + return self::all()[$code]['label'] ?? $code; + } + + /** + * Options prêtes pour un sélecteur côté frontend. + * + * @return array + */ + public static function options(): array + { + $out = []; + foreach (self::all() as $code => $def) { + $out[] = ['code' => $code, 'label' => $def['label'], 'flag' => $def['flag']]; + } + + return $out; + } +} diff --git a/app/Http/Controllers/Settings/LanguageController.php b/app/Http/Controllers/Settings/LanguageController.php new file mode 100644 index 00000000..e0793396 --- /dev/null +++ b/app/Http/Controllers/Settings/LanguageController.php @@ -0,0 +1,28 @@ +validate([ + 'locale' => ['required', 'string', Rule::in(Locales::codes())], + ]); + + $request->user()?->update(['locale' => $data['locale']]); + $request->session()->put('locale', $data['locale']); + + return back(); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index cc18a373..8a85a29f 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -3,8 +3,10 @@ namespace App\Http\Middleware; use App\Constants\Currencies; +use App\Constants\Locales; use App\Models\School; use Illuminate\Http\Request; +use Illuminate\Support\Facades\App; use Inertia\Middleware; class HandleInertiaRequests extends Middleware @@ -53,9 +55,47 @@ public function share(Request $request): array 'settings' => [ 'currency' => $user ? $this->currency() : self::defaultCurrency(), ], + // Internationalisation : langue active, langues disponibles et dictionnaire + // de la langue active (la langue par défaut sert de repli pour les clés absentes). + 'locale' => App::getLocale(), + 'locales' => Locales::options(), + 'translations' => $this->translations(App::getLocale()), ]; } + /** + * Dictionnaire de traduction d'une langue : chaque fichier `lang/{code}/*.php` + * devient un groupe. La langue par défaut est fusionnée dessous comme repli. + * + * @return array + */ + private function translations(string $locale): array + { + $fallback = $this->loadLang(config('app.fallback_locale', Locales::DEFAULT)); + $active = $this->loadLang($locale); + + return array_replace_recursive($fallback, $active); + } + + /** + * Charge tous les groupes de `lang/{locale}/*.php`. + * + * @return array + */ + private function loadLang(string $locale): array + { + $out = []; + $dir = lang_path($locale); + + if (is_dir($dir)) { + foreach (glob($dir.'/*.php') as $file) { + $out[basename($file, '.php')] = require $file; + } + } + + return $out; + } + /** * Monnaie de l'établissement (code + symbole) pour le formatage des montants côté front. * diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php new file mode 100644 index 00000000..128e2485 --- /dev/null +++ b/app/Http/Middleware/SetLocale.php @@ -0,0 +1,29 @@ +user()?->locale + ?? $request->session()->get('locale') + ?? config('app.locale'); + + $locale = Locales::isValid($candidate) ? $candidate : Locales::DEFAULT; + + App::setLocale($locale); + + return $next($request); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index fa842db5..747b736a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -55,6 +55,7 @@ class User extends Authenticatable 'password', 'is_demo', 'must_change_password', + 'locale', ]; /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 6f392011..15c3e254 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Http\Middleware\ForcePasswordChange; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\SetLocale; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -24,6 +25,7 @@ $middleware->web(append: [ HandleAppearance::class, + SetLocale::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ForcePasswordChange::class, diff --git a/config/app.php b/config/app.php index ccc7d996..9c8abddc 100644 --- a/config/app.php +++ b/config/app.php @@ -81,9 +81,9 @@ | */ - 'locale' => env('APP_LOCALE', 'en'), + 'locale' => env('APP_LOCALE', 'fr'), - 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'fr'), 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), diff --git a/database/migrations/2026_10_21_000001_add_locale_to_users_table.php b/database/migrations/2026_10_21_000001_add_locale_to_users_table.php new file mode 100644 index 00000000..7700f758 --- /dev/null +++ b/database/migrations/2026_10_21_000001_add_locale_to_users_table.php @@ -0,0 +1,23 @@ +string('locale', 5)->nullable()->after('email'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('locale'); + }); + } +}; diff --git a/lang/en/common.php b/lang/en/common.php new file mode 100644 index 00000000..21ccb6c6 --- /dev/null +++ b/lang/en/common.php @@ -0,0 +1,47 @@ + [ + 'save' => 'Save', + 'cancel' => 'Cancel', + 'create' => 'Create', + 'add' => 'Add', + 'edit' => 'Edit', + 'update' => 'Update', + 'delete' => 'Delete', + 'remove' => 'Remove', + 'search' => 'Search', + 'filter' => 'Filter', + 'reset' => 'Reset', + 'confirm' => 'Confirm', + 'back' => 'Back', + 'close' => 'Close', + 'print' => 'Print', + 'export' => 'Export', + 'download' => 'Download', + 'view' => 'View', + 'details' => 'Details', + 'validate' => 'Validate', + 'next' => 'Next', + 'previous' => 'Previous', + 'yes' => 'Yes', + 'no' => 'No', + ], + 'status' => [ + 'active' => 'Active', + 'inactive' => 'Inactive', + 'pending' => 'Pending', + 'loading' => 'Loading…', + ], + 'messages' => [ + 'none' => 'No results', + 'confirm_delete' => 'Confirm deletion', + 'saved' => 'Saved', + 'required' => 'This field is required', + ], + 'pagination' => [ + 'showing' => 'Showing :from to :to of :total', + 'per_page' => 'Rows per page', + 'page' => 'Page', + ], +]; diff --git a/lang/en/menu.php b/lang/en/menu.php new file mode 100644 index 00000000..5c7fb621 --- /dev/null +++ b/lang/en/menu.php @@ -0,0 +1,82 @@ + 'Dashboard', + 'calendar' => 'Calendar', + + 'students' => 'Students', + 'students_list' => 'Students', + 'enrollments' => 'Enrollments & Payments', + 'roster' => 'Rosters / Lists', + 'promotion' => 'Class promotion', + 'student_stats' => 'Student statistics', + 'student_scholarships' => 'Student scholarships', + 'timetable' => 'Timetable', + + 'attendance' => 'Attendance', + 'attendance_entry' => 'Take attendance', + 'attendance_stats' => 'Statistics', + 'absence_permissions' => 'Absence requests', + + 'exams' => 'Exams', + 'evaluation_templates' => 'Evaluation templates', + 'evaluations' => 'Class evaluations', + 'exam_planning' => 'Exam planning', + 'official_exams' => 'Official exams', + + 'grades' => 'Grades', + 'grades_entry' => 'Grade entry', + 'bulletins' => 'Report cards', + 'bulletin_template' => 'Report card template', + 'note_reclamations' => 'Grade claims', + + 'accounting' => 'Accounting', + 'accounting_overview' => 'Overview', + 'transactions' => 'Transaction journal', + 'new_expense' => 'New expense', + 'situation' => 'Status by class', + 'cash_accounts' => 'Cash accounts', + 'verify_receipt' => 'Verify a receipt', + + 'payroll' => 'Staff & Payroll', + 'payroll_overview' => 'Overview', + 'personnel' => 'Staff', + 'pay_runs' => 'Pay runs', + 'salary_grades' => 'Salary grades', + 'salary_components' => 'Pay components', + 'payroll_settings' => 'Settings (CNSS, ITS)', + + 'archives' => 'Archives', + 'archived_documents' => 'Archived documents', + 'tags' => 'Tags', + + 'administration' => 'Administration', + 'users' => 'Users', + 'roles' => 'Roles', + 'permissions' => 'Permissions', + 'subject_assignments' => 'Assignments', + 'audit_logs' => 'Audit log', + 'portal_access' => 'Portal access', + + 'statistics' => 'Statistics', + + 'settings' => 'Settings', + 'school' => 'School', + 'classrooms' => 'Classes', + 'classroom_types' => 'Class types', + 'subjects' => 'Subjects', + 'countries' => 'Countries', + 'evaluation_types' => 'Evaluation types', + 'academic_years' => 'Academic years', + 'academic_periods' => 'Academic periods', + 'fee_categories' => 'Fee categories', + 'fee_structures' => 'Fee structures', + 'scholarships' => 'Scholarships', + 'grading_configs' => 'Grade calculation', + 'file_storage' => 'Files & Storage', + 'document_templates' => 'Document templates', + 'backups' => 'Backups', + 'about' => 'About', + + 'my_account' => 'My account', +]; diff --git a/lang/en/settings.php b/lang/en/settings.php new file mode 100644 index 00000000..dcae9e9b --- /dev/null +++ b/lang/en/settings.php @@ -0,0 +1,29 @@ + 'Settings', + 'account' => 'My account', + + 'profile' => [ + 'title' => 'Profile', + 'desc' => 'Your personal information', + ], + 'password' => [ + 'title' => 'Password', + 'desc' => 'Change your password', + ], + 'appearance' => [ + 'title' => 'Appearance', + 'desc' => 'Light, dark or system theme', + ], + 'two_factor' => [ + 'title' => 'Two-factor authentication', + 'desc' => 'Secure your account with a second factor', + ], + 'language' => [ + 'title' => 'Language', + 'desc' => 'Interface language, saved to your profile', + 'label' => 'Interface language', + 'updated' => 'Language updated', + ], +]; diff --git a/lang/fr/common.php b/lang/fr/common.php new file mode 100644 index 00000000..9de57286 --- /dev/null +++ b/lang/fr/common.php @@ -0,0 +1,47 @@ + [ + 'save' => 'Enregistrer', + 'cancel' => 'Annuler', + 'create' => 'Créer', + 'add' => 'Ajouter', + 'edit' => 'Modifier', + 'update' => 'Mettre à jour', + 'delete' => 'Supprimer', + 'remove' => 'Retirer', + 'search' => 'Rechercher', + 'filter' => 'Filtrer', + 'reset' => 'Réinitialiser', + 'confirm' => 'Confirmer', + 'back' => 'Retour', + 'close' => 'Fermer', + 'print' => 'Imprimer', + 'export' => 'Exporter', + 'download' => 'Télécharger', + 'view' => 'Voir', + 'details' => 'Détails', + 'validate' => 'Valider', + 'next' => 'Suivant', + 'previous' => 'Précédent', + 'yes' => 'Oui', + 'no' => 'Non', + ], + 'status' => [ + 'active' => 'Actif', + 'inactive' => 'Inactif', + 'pending' => 'En attente', + 'loading' => 'Chargement…', + ], + 'messages' => [ + 'none' => 'Aucun résultat', + 'confirm_delete' => 'Confirmer la suppression', + 'saved' => 'Enregistré', + 'required' => 'Ce champ est obligatoire', + ], + 'pagination' => [ + 'showing' => 'Affichage de :from à :to sur :total', + 'per_page' => 'Lignes par page', + 'page' => 'Page', + ], +]; diff --git a/lang/fr/menu.php b/lang/fr/menu.php new file mode 100644 index 00000000..b8db70fb --- /dev/null +++ b/lang/fr/menu.php @@ -0,0 +1,82 @@ + 'Tableau de bord', + 'calendar' => 'Calendrier', + + 'students' => 'Élèves', + 'students_list' => 'Élèves', + 'enrollments' => 'Inscriptions & Paiements', + 'roster' => 'Effectifs / Listes', + 'promotion' => 'Passage de classe', + 'student_stats' => 'Statistiques élèves', + 'student_scholarships' => "Bourses d'étudiants", + 'timetable' => 'Emploi du temps', + + 'attendance' => 'Présences', + 'attendance_entry' => "Saisie de l'appel", + 'attendance_stats' => 'Statistiques', + 'absence_permissions' => 'Demandes de permission', + + 'exams' => 'Examens', + 'evaluation_templates' => "Modèles d'évaluation", + 'evaluations' => 'Évaluations par classe', + 'exam_planning' => 'Planning des examens', + 'official_exams' => 'Examens officiels', + + 'grades' => 'Notes', + 'grades_entry' => 'Saisie de notes', + 'bulletins' => 'Bulletins', + 'bulletin_template' => 'Modèle de bulletin', + 'note_reclamations' => 'Réclamations', + + 'accounting' => 'Comptabilité', + 'accounting_overview' => "Vue d'ensemble", + 'transactions' => 'Journal des transactions', + 'new_expense' => 'Nouvelle dépense', + 'situation' => 'Situation par classe', + 'cash_accounts' => 'Caisses', + 'verify_receipt' => 'Vérifier un reçu', + + 'payroll' => 'Personnel & Paie', + 'payroll_overview' => "Vue d'ensemble", + 'personnel' => 'Personnel', + 'pay_runs' => 'Cycles de paie', + 'salary_grades' => 'Grilles salariales', + 'salary_components' => 'Rubriques de paie', + 'payroll_settings' => 'Réglages (CNSS, ITS)', + + 'archives' => 'Archives', + 'archived_documents' => 'Documents archivés', + 'tags' => 'Tags', + + 'administration' => 'Administration', + 'users' => 'Utilisateurs', + 'roles' => 'Rôles', + 'permissions' => 'Permissions', + 'subject_assignments' => 'Affectations', + 'audit_logs' => "Journal d'audit", + 'portal_access' => 'Accès portail', + + 'statistics' => 'Statistiques', + + 'settings' => 'Paramètres', + 'school' => 'École', + 'classrooms' => 'Classes', + 'classroom_types' => 'Types de classes', + 'subjects' => 'Matières', + 'countries' => 'Pays', + 'evaluation_types' => "Types d'évaluation", + 'academic_years' => 'Années académiques', + 'academic_periods' => 'Périodes académiques', + 'fee_categories' => 'Catégories de frais', + 'fee_structures' => 'Structures de frais', + 'scholarships' => 'Bourses', + 'grading_configs' => 'Calcul des moyennes', + 'file_storage' => 'Fichiers & Stockage', + 'document_templates' => 'Modèles de documents', + 'backups' => 'Sauvegardes', + 'about' => 'À propos', + + 'my_account' => 'Mon compte', +]; diff --git a/lang/fr/settings.php b/lang/fr/settings.php new file mode 100644 index 00000000..e1cb349e --- /dev/null +++ b/lang/fr/settings.php @@ -0,0 +1,29 @@ + 'Paramètres', + 'account' => 'Mon compte', + + 'profile' => [ + 'title' => 'Profil', + 'desc' => 'Vos informations personnelles', + ], + 'password' => [ + 'title' => 'Mot de passe', + 'desc' => 'Modifier votre mot de passe', + ], + 'appearance' => [ + 'title' => 'Apparence', + 'desc' => 'Thème clair, sombre ou système', + ], + 'two_factor' => [ + 'title' => 'Double authentification', + 'desc' => 'Sécurisez votre compte avec un second facteur', + ], + 'language' => [ + 'title' => 'Langue', + 'desc' => "Langue de l'interface, enregistrée sur votre profil", + 'label' => "Langue de l'interface", + 'updated' => 'Langue mise à jour', + ], +]; diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx index 324062cb..82dca4ce 100644 --- a/resources/js/components/app-sidebar-header.tsx +++ b/resources/js/components/app-sidebar-header.tsx @@ -16,6 +16,7 @@ import { } from '@/components/ui/dropdown-menu'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { UserMenuContent } from '@/components/user-menu-content'; +import { useTranslation } from '@/helpers/i18n'; import { useCurrentUrl } from '@/hooks/use-current-url'; import { getFullName } from '@/hooks/use-initials'; import { mainNavItems } from '@/types'; @@ -23,6 +24,7 @@ import { mainNavItems } from '@/types'; export function AppSidebarHeader() { const { auth } = usePage().props; const { currentUrl, findActiveKey } = useCurrentUrl(); + const { t } = useTranslation(); const fullName = getFullName(auth.user.firstname, auth.user.lastname); const initials = `${auth.user.firstname?.[0] ?? ''}${auth.user.lastname?.[0] ?? ''}`.toUpperCase(); @@ -62,9 +64,9 @@ export function AppSidebarHeader() { {i > 0 && } {i === trail.length - 1 ? ( - {label} + {t(label)} ) : ( - {label} + {t(label)} )} diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/nav-main.tsx index 4b2cb563..d1c411ef 100644 --- a/resources/js/components/nav-main.tsx +++ b/resources/js/components/nav-main.tsx @@ -15,6 +15,7 @@ import { SidebarMenuSubButton, SidebarMenuSubItem, } from '@/components/ui/sidebar'; +import { useTranslation } from '@/helpers/i18n'; import { useCurrentUrl } from '@/hooks/use-current-url'; import type { NavItem } from '@/types'; @@ -24,6 +25,7 @@ const ACTIVE_CLASS = export function NavMain({ items = [] }: Readonly<{ items: NavItem[] }>) { const { currentUrl, findActiveKey } = useCurrentUrl(); + const { t } = useTranslation(); const auth = (usePage().props as { auth?: { permissions?: string[] } }).auth; const permissions = auth?.permissions ?? []; @@ -91,11 +93,11 @@ export function NavMain({ items = [] }: Readonly<{ items: NavItem[] }>) { {item.icon && } - {item.title} + {t(item.title)} @@ -110,7 +112,7 @@ export function NavMain({ items = [] }: Readonly<{ items: NavItem[] }>) { > {subItem.icon && } - {subItem.title} + {t(subItem.title)} @@ -122,12 +124,12 @@ export function NavMain({ items = [] }: Readonly<{ items: NavItem[] }>) { {item.icon && } - {item.title} + {t(item.title)} )} diff --git a/resources/js/helpers/i18n.ts b/resources/js/helpers/i18n.ts new file mode 100644 index 00000000..9460d3a8 --- /dev/null +++ b/resources/js/helpers/i18n.ts @@ -0,0 +1,52 @@ +import { usePage } from '@inertiajs/react'; + +type Translations = Record; + +export interface LocaleOption { + code: string; + label: string; + flag: string; +} + +interface SharedI18n { + translations?: Translations; + locale?: string; + locales?: LocaleOption[]; +} + +/** Résout une clé pointée (« menu.dashboard ») dans le dictionnaire imbriqué. */ +function resolve(dict: Translations, key: string): string | undefined { + const value = key.split('.').reduce((acc, part) => { + if (acc && typeof acc === 'object') { + return (acc as Record)[part]; + } + return undefined; + }, dict); + + return typeof value === 'string' ? value : undefined; +} + +/** Remplace les jetons `:param` par leur valeur. */ +function interpolate(text: string, params?: Record): string { + if (!params) { + return text; + } + return text.replace(/:(\w+)/g, (match, name) => (name in params ? String(params[name]) : match)); +} + +/** + * Traduction côté front, alimentée par les fichiers de langue Laravel partagés + * via Inertia. `t('menu.dashboard')` ; interpolation : `t('x', { name })`. + * Clé absente → repli sur la langue par défaut (fusionnée côté serveur) puis sur la clé. + */ +export function useTranslation() { + const props = usePage().props as unknown as SharedI18n; + const translations = props.translations ?? {}; + const locale = props.locale ?? 'fr'; + const locales = props.locales ?? []; + + const t = (key: string, params?: Record): string => + interpolate(resolve(translations, key) ?? key, params); + + return { t, locale, locales }; +} diff --git a/resources/js/pages/settings/appearance.tsx b/resources/js/pages/settings/appearance.tsx index 54c41b5c..74fd0b16 100644 --- a/resources/js/pages/settings/appearance.tsx +++ b/resources/js/pages/settings/appearance.tsx @@ -1,24 +1,34 @@ -import { Head } from '@inertiajs/react'; -import { Palette } from 'lucide-react'; +import { Head, router } from '@inertiajs/react'; +import { Palette, Languages } from 'lucide-react'; import AppearanceTabs from '@/components/appearance-tabs'; +import { useTranslation } from '@/helpers/i18n'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; import { edit as editAppearance } from '@/routes/appearance'; import type { BreadcrumbItem } from '@/types'; -const breadcrumbs: BreadcrumbItem[] = [ - { - title: 'Apparence', - href: editAppearance().url, - }, -]; - export default function Appearance() { + const { t, locale, locales } = useTranslation(); + + const breadcrumbs: BreadcrumbItem[] = [ + { + title: t('settings.appearance.title'), + href: editAppearance().url, + }, + ]; + + const changeLanguage = (value: string) => { + if (value === locale) { + return; + } + router.patch('/settings/language', { locale: value }, { preserveScroll: true }); + }; + return ( - + -

Apparence

+

{t('settings.appearance.title')}

@@ -29,17 +39,41 @@ export default function Appearance() {
-

Apparence

-

Personnalisez l'apparence de votre compte.

+

{t('settings.appearance.title')}

+

{t('settings.appearance.desc')}

- {/* Contenu */} + {/* Thème */}
+ + {/* Langue */} +
+
+ +

{t('settings.language.title')}

+
+

{t('settings.language.desc')}

+ + +
diff --git a/resources/js/types/menu.ts b/resources/js/types/menu.ts index 62622903..4b485058 100644 --- a/resources/js/types/menu.ts +++ b/resources/js/types/menu.ts @@ -2,143 +2,145 @@ import { BookOpen, Calendar, Clock, ClipboardList, GraduationCap, LayoutGrid, La import { route } from '@/helpers/route'; import type { NavItem } from '@/types'; +// NB : `title` porte une CLÉ de traduction (ex. 'menu.dashboard'), résolue à +// l'affichage via useTranslation() dans nav-main / app-sidebar-header. export const mainNavItems: NavItem[] = [ { - title: 'Tableau de bord', + title: 'menu.dashboard', href: '/dashboard', icon: LayoutGrid, }, { - title: 'Calendrier', + title: 'menu.calendar', href: route('calendar.index'), icon: Calendar, permission: 'view_calendar', }, { - title: 'Élèves', + title: 'menu.students', href: '#', icon: Users, items: [ - { title: 'Élèves', href: route('students.index'), icon: Users, permission: 'view_students' }, - { title: 'Inscriptions & Paiements', href: route('enrollments.index'), icon: ClipboardList, permission: 'view_enrollments' }, - { title: 'Effectifs / Listes', href: route('roster.index'), icon: ListChecks, permission: 'view_roster' }, - { title: 'Passage de classe', href: route('promotion.index'), icon: ArrowLeftRight, permission: 'execute_promotion' }, - { title: 'Statistiques élèves', href: route('students.stats'), icon: BarChart3, permission: 'view_students' }, - { title: 'Bourses d\'étudiants', href: route('student-scholarships.index'), icon: Percent, permission: 'view_student_scholarships' }, - { title: 'Emploi du temps', href: route('timetable.index'), icon: CalendarRange, permission: 'view_timetable' }, + { title: 'menu.students_list', href: route('students.index'), icon: Users, permission: 'view_students' }, + { title: 'menu.enrollments', href: route('enrollments.index'), icon: ClipboardList, permission: 'view_enrollments' }, + { title: 'menu.roster', href: route('roster.index'), icon: ListChecks, permission: 'view_roster' }, + { title: 'menu.promotion', href: route('promotion.index'), icon: ArrowLeftRight, permission: 'execute_promotion' }, + { title: 'menu.student_stats', href: route('students.stats'), icon: BarChart3, permission: 'view_students' }, + { title: 'menu.student_scholarships', href: route('student-scholarships.index'), icon: Percent, permission: 'view_student_scholarships' }, + { title: 'menu.timetable', href: route('timetable.index'), icon: CalendarRange, permission: 'view_timetable' }, ], }, { - title: 'Présences', + title: 'menu.attendance', href: '#', icon: UserCheck, items: [ - { title: 'Saisie de l\'appel', href: route('attendances.index'), icon: ClipboardList, permission: 'view_attendances' }, - { title: 'Statistiques', href: route('attendances.stats'), icon: BarChart3, permission: 'view_attendances' }, - { title: 'Demandes de permission', href: route('absence-permissions.index'), icon: ShieldCheck, permission: 'view_absence_permissions' }, + { title: 'menu.attendance_entry', href: route('attendances.index'), icon: ClipboardList, permission: 'view_attendances' }, + { title: 'menu.attendance_stats', href: route('attendances.stats'), icon: BarChart3, permission: 'view_attendances' }, + { title: 'menu.absence_permissions', href: route('absence-permissions.index'), icon: ShieldCheck, permission: 'view_absence_permissions' }, ], }, { - title: 'Examens', + title: 'menu.exams', href: '#', icon: FileText, items: [ - { title: 'Modèles d\'évaluation', href: route('evaluation-templates.index'), icon: ListChecks, permission: 'view_evaluation_templates' }, - { title: 'Évaluations par classe', href: route('evaluations.index'), icon: ClipboardList, permission: 'view_evaluations' }, - { title: 'Planning des examens', href: route('evaluations.planning'), icon: CalendarRange, permission: 'view_evaluations' }, - { title: 'Examens officiels', href: route('official-exams.index'), icon: GraduationCap, permission: 'view_official_exams' }, + { title: 'menu.evaluation_templates', href: route('evaluation-templates.index'), icon: ListChecks, permission: 'view_evaluation_templates' }, + { title: 'menu.evaluations', href: route('evaluations.index'), icon: ClipboardList, permission: 'view_evaluations' }, + { title: 'menu.exam_planning', href: route('evaluations.planning'), icon: CalendarRange, permission: 'view_evaluations' }, + { title: 'menu.official_exams', href: route('official-exams.index'), icon: GraduationCap, permission: 'view_official_exams' }, ], }, { - title: 'Notes', + title: 'menu.grades', href: '#', icon: NotebookPen, items: [ - { title: 'Saisie de notes', href: route('grades.index'), icon: ClipboardList, permission: 'view_grades' }, - { title: 'Bulletins', href: route('bulletins.index'), icon: FileBadge, permission: 'view_bulletins' }, - { title: 'Modèle de bulletin', href: route('bulletin-templates.edit'), icon: SlidersHorizontal, permission: 'view_bulletin_templates' }, - { title: 'Réclamations', href: route('note-reclamations.index'), icon: AlertCircle, permission: 'view_note_reclamations' }, + { title: 'menu.grades_entry', href: route('grades.index'), icon: ClipboardList, permission: 'view_grades' }, + { title: 'menu.bulletins', href: route('bulletins.index'), icon: FileBadge, permission: 'view_bulletins' }, + { title: 'menu.bulletin_template', href: route('bulletin-templates.edit'), icon: SlidersHorizontal, permission: 'view_bulletin_templates' }, + { title: 'menu.note_reclamations', href: route('note-reclamations.index'), icon: AlertCircle, permission: 'view_note_reclamations' }, ], }, { - title: 'Comptabilité', + title: 'menu.accounting', href: '#', icon: TrendingUp, items: [ - { title: 'Vue d\'ensemble', href: route('accounting.index'), icon: TrendingUp, permission: 'view_finances' }, - { title: 'Journal des transactions', href: route('accounting.transactions'), icon: ArrowLeftRight, permission: 'view_transactions' }, - { title: 'Nouvelle dépense', href: route('expenses.create'), icon: TrendingDown, permission: 'create_expenses' }, - { title: 'Situation par classe', href: route('accounting.situation'), icon: PieChart, permission: 'view_finances' }, - { title: 'Caisses', href: route('cash-accounts.index'), icon: Wallet, permission: 'view_cash_accounts' }, - { title: 'Vérifier un reçu', href: route('receipts.verify'), icon: ScanLine, permission: 'view_invoices' }, + { title: 'menu.accounting_overview', href: route('accounting.index'), icon: TrendingUp, permission: 'view_finances' }, + { title: 'menu.transactions', href: route('accounting.transactions'), icon: ArrowLeftRight, permission: 'view_transactions' }, + { title: 'menu.new_expense', href: route('expenses.create'), icon: TrendingDown, permission: 'create_expenses' }, + { title: 'menu.situation', href: route('accounting.situation'), icon: PieChart, permission: 'view_finances' }, + { title: 'menu.cash_accounts', href: route('cash-accounts.index'), icon: Wallet, permission: 'view_cash_accounts' }, + { title: 'menu.verify_receipt', href: route('receipts.verify'), icon: ScanLine, permission: 'view_invoices' }, ], }, { - title: 'Personnel & Paie', + title: 'menu.payroll', href: '#', icon: Briefcase, items: [ - { title: 'Vue d\'ensemble', href: route('payroll.overview'), icon: LayoutGrid, permission: 'view_payroll' }, - { title: 'Personnel', href: route('personnel.index'), icon: Users, permission: 'view_employees' }, - { title: 'Cycles de paie', href: route('pay-runs.index'), icon: Receipt, permission: 'view_payroll' }, - { title: 'Grilles salariales', href: route('salary-grades.index'), icon: Layers, permission: 'view_salary_grades' }, - { title: 'Rubriques de paie', href: route('salary-components.index'), icon: Coins, permission: 'view_salary_components' }, - { title: 'Réglages (CNSS, ITS)', href: route('payroll-settings.edit'), icon: SlidersHorizontal, permission: 'edit_salary_grades' }, + { title: 'menu.payroll_overview', href: route('payroll.overview'), icon: LayoutGrid, permission: 'view_payroll' }, + { title: 'menu.personnel', href: route('personnel.index'), icon: Users, permission: 'view_employees' }, + { title: 'menu.pay_runs', href: route('pay-runs.index'), icon: Receipt, permission: 'view_payroll' }, + { title: 'menu.salary_grades', href: route('salary-grades.index'), icon: Layers, permission: 'view_salary_grades' }, + { title: 'menu.salary_components', href: route('salary-components.index'), icon: Coins, permission: 'view_salary_components' }, + { title: 'menu.payroll_settings', href: route('payroll-settings.edit'), icon: SlidersHorizontal, permission: 'edit_salary_grades' }, ], }, { - title: 'Archives', + title: 'menu.archives', href: '#', icon: Archive, items: [ - { title: 'Documents archivés', href: route('archives.index'), icon: FileText, permission: 'view_archives' }, - { title: 'Tags', href: route('archives.tags.index'), icon: Tag, permission: 'view_archives' }, + { title: 'menu.archived_documents', href: route('archives.index'), icon: FileText, permission: 'view_archives' }, + { title: 'menu.tags', href: route('archives.tags.index'), icon: Tag, permission: 'view_archives' }, ], }, { - title: 'Administration', + title: 'menu.administration', href: '#', icon: Shield, items: [ - { title: 'Utilisateurs', href: route('users.index'), icon: Users, permission: 'view_users' }, - { title: 'Rôles', href: route('roles.index'), icon: Shield, permission: 'manage_roles_permissions' }, - { title: 'Permissions', href: route('permissions.index'), icon: Lock, permission: 'manage_roles_permissions' }, - { title: 'Affectations', href: route('subject-assignments.index'), icon: ClipboardList, permission: 'view_subject_assignments' }, - { title: "Journal d'audit", href: route('audit-logs.index'), icon: ScanLine, permission: 'view_audit_logs' }, - { title: 'Accès portail', href: route('guardians.index'), icon: UserCheck, permission: 'view_portal_accounts' }, + { title: 'menu.users', href: route('users.index'), icon: Users, permission: 'view_users' }, + { title: 'menu.roles', href: route('roles.index'), icon: Shield, permission: 'manage_roles_permissions' }, + { title: 'menu.permissions', href: route('permissions.index'), icon: Lock, permission: 'manage_roles_permissions' }, + { title: 'menu.subject_assignments', href: route('subject-assignments.index'), icon: ClipboardList, permission: 'view_subject_assignments' }, + { title: 'menu.audit_logs', href: route('audit-logs.index'), icon: ScanLine, permission: 'view_audit_logs' }, + { title: 'menu.portal_access', href: route('guardians.index'), icon: UserCheck, permission: 'view_portal_accounts' }, ], }, { - title: 'Statistiques', + title: 'menu.statistics', href: route('statistics.index'), icon: PieChart, permission: 'view_statistics', }, { - title: 'Paramètres', + title: 'menu.settings', href: '#', icon: Settings, items: [ - { title: 'École', href: route('schools.index'), icon: BookOpen, permission: 'view_schools' }, - { title: 'Classes', href: route('classrooms.index'), icon: Layers, permission: 'view_classes' }, - { title: 'Types de classes', href: route('classroom-types.index'), icon: Tag, permission: 'view_classroom_types' }, - { title: 'Matières', href: route('subjects.index'), icon: BookOpen, permission: 'view_subjects' }, - { title: 'Pays', href: route('countries.index'), icon: Globe, permission: 'view_countries' }, - { title: 'Types d\'évaluation', href: route('evaluation-types.index'), icon: Tag, permission: 'view_evaluation_types' }, - { title: 'Années académiques', href: route('academic-years.index'), icon: Calendar, permission: 'view_academic_years' }, - { title: 'Périodes académiques', href: route('academic-periods.index'), icon: Clock, permission: 'view_academic_periods' }, - { title: 'Catégories de frais', href: route('fee-categories.index'), icon: Percent, permission: 'view_fee_categories' }, - { title: 'Structures de frais', href: route('fee-structures.index'), icon: DollarSign, permission: 'view_fee_structures' }, - { title: 'Bourses', href: route('scholarships.index'), icon: Percent, permission: 'view_scholarships' }, - { title: 'Calcul des moyennes', href: route('grading-configs.index'), icon: SlidersHorizontal, permission: 'view_grading_configs' }, - { title: 'Fichiers & Stockage', href: route('file-storage.index'), icon: HardDrive, permission: 'manage_file_storage' }, - { title: 'Modèles de documents', href: route('document-templates.index'), icon: FileBadge, permission: 'view_documents' }, - { title: 'Sauvegardes', href: route('backups.index'), icon: DatabaseBackup, permission: 'view_backups' }, - { title: 'À propos', href: route('about.index'), icon: Info }, + { title: 'menu.school', href: route('schools.index'), icon: BookOpen, permission: 'view_schools' }, + { title: 'menu.classrooms', href: route('classrooms.index'), icon: Layers, permission: 'view_classes' }, + { title: 'menu.classroom_types', href: route('classroom-types.index'), icon: Tag, permission: 'view_classroom_types' }, + { title: 'menu.subjects', href: route('subjects.index'), icon: BookOpen, permission: 'view_subjects' }, + { title: 'menu.countries', href: route('countries.index'), icon: Globe, permission: 'view_countries' }, + { title: 'menu.evaluation_types', href: route('evaluation-types.index'), icon: Tag, permission: 'view_evaluation_types' }, + { title: 'menu.academic_years', href: route('academic-years.index'), icon: Calendar, permission: 'view_academic_years' }, + { title: 'menu.academic_periods', href: route('academic-periods.index'), icon: Clock, permission: 'view_academic_periods' }, + { title: 'menu.fee_categories', href: route('fee-categories.index'), icon: Percent, permission: 'view_fee_categories' }, + { title: 'menu.fee_structures', href: route('fee-structures.index'), icon: DollarSign, permission: 'view_fee_structures' }, + { title: 'menu.scholarships', href: route('scholarships.index'), icon: Percent, permission: 'view_scholarships' }, + { title: 'menu.grading_configs', href: route('grading-configs.index'), icon: SlidersHorizontal, permission: 'view_grading_configs' }, + { title: 'menu.file_storage', href: route('file-storage.index'), icon: HardDrive, permission: 'manage_file_storage' }, + { title: 'menu.document_templates', href: route('document-templates.index'), icon: FileBadge, permission: 'view_documents' }, + { title: 'menu.backups', href: route('backups.index'), icon: DatabaseBackup, permission: 'view_backups' }, + { title: 'menu.about', href: route('about.index'), icon: Info }, ], }, { - title: 'Mon compte', + title: 'menu.my_account', href: '/settings/profile', icon: UserCircle, // Les réglages du compte n'ont pas de préfixe commun exclusif : sans ces diff --git a/routes/settings.php b/routes/settings.php index d6e03ad9..6f2b7f3b 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -1,5 +1,6 @@ name('profile.edit'); Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update'); + + // Langue de l'interface (enregistrée sur le profil + en session). + Route::patch('settings/language', [LanguageController::class, 'update'])->name('language.update'); }); Route::middleware(['auth', 'verified'])->group(function () { diff --git a/tests/Feature/LocaleTest.php b/tests/Feature/LocaleTest.php new file mode 100644 index 00000000..3aa73381 --- /dev/null +++ b/tests/Feature/LocaleTest.php @@ -0,0 +1,53 @@ +create(array_merge([ + 'locale' => null, + 'must_change_password' => false, + ], $overrides)); + } + + public function test_user_can_change_language(): void + { + $user = $this->user(); + + $this->actingAs($user) + ->patch('/settings/language', ['locale' => 'en']) + ->assertRedirect(); + + $this->assertSame('en', $user->fresh()->locale); + } + + public function test_invalid_locale_is_rejected(): void + { + $user = $this->user(['locale' => 'fr']); + + $this->actingAs($user) + ->patch('/settings/language', ['locale' => 'de']) + ->assertSessionHasErrors('locale'); + + $this->assertSame('fr', $user->fresh()->locale); + } + + public function test_locale_and_translations_are_shared(): void + { + $user = $this->user(['locale' => 'en']); + + $this->actingAs($user) + ->get('/settings/profile') + ->assertInertia(fn ($page) => $page + ->where('locale', 'en') + ->where('translations.menu.dashboard', 'Dashboard')); + } +} From 726e7373772d798c9b8a86a2c396982de6625b1f Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Wed, 21 Oct 2026 11:08:17 +0000 Subject: [PATCH 02/68] =?UTF-8?q?feat(i18n):=20traduit=20le=20reste=20de?= =?UTF-8?q?=20la=20coquille=20(dropdown,=20param=C3=A8tres,=20login)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dropdown utilisateur (Paramètres, Déconnexion) via t(). - Sous-navigation des Paramètres (onglets Profil / Mot de passe / 2FA / Apparence + en-tête « Mon compte ») via t(). - Page de connexion (titres, libellés, boutons) via t(). - Ajout des fichiers lang auth (fr/en) et de settings.account_desc. --- lang/en/auth.php | 16 ++++++++ lang/en/settings.php | 1 + lang/fr/auth.php | 16 ++++++++ lang/fr/settings.php | 1 + resources/js/components/user-menu-content.tsx | 6 ++- resources/js/layouts/settings/layout.tsx | 37 ++++++------------- resources/js/pages/auth/login.tsx | 22 ++++++----- 7 files changed, 62 insertions(+), 37 deletions(-) create mode 100644 lang/en/auth.php create mode 100644 lang/fr/auth.php diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 00000000..989bff4d --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,16 @@ + [ + 'title' => 'Sign in', + 'description' => 'Enter your credentials to access your account', + 'email' => 'Email address', + 'email_placeholder' => 'you@example.com', + 'password' => 'Password', + 'password_placeholder' => 'Password', + 'forgot' => 'Forgot your password?', + 'remember' => 'Remember me', + 'submit' => 'Sign in', + ], + 'logout' => 'Log out', +]; diff --git a/lang/en/settings.php b/lang/en/settings.php index dcae9e9b..e3658c75 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -3,6 +3,7 @@ return [ 'title' => 'Settings', 'account' => 'My account', + 'account_desc' => 'Manage your profile and account settings', 'profile' => [ 'title' => 'Profile', diff --git a/lang/fr/auth.php b/lang/fr/auth.php new file mode 100644 index 00000000..284f3e5a --- /dev/null +++ b/lang/fr/auth.php @@ -0,0 +1,16 @@ + [ + 'title' => 'Connexion', + 'description' => 'Entrez vos identifiants pour accéder à votre compte', + 'email' => 'Adresse e-mail', + 'email_placeholder' => 'vous@exemple.com', + 'password' => 'Mot de passe', + 'password_placeholder' => 'Mot de passe', + 'forgot' => 'Mot de passe oublié ?', + 'remember' => 'Se souvenir de moi', + 'submit' => 'Se connecter', + ], + 'logout' => 'Déconnexion', +]; diff --git a/lang/fr/settings.php b/lang/fr/settings.php index e1cb349e..76bb19d5 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -3,6 +3,7 @@ return [ 'title' => 'Paramètres', 'account' => 'Mon compte', + 'account_desc' => 'Gérez votre profil et les paramètres de votre compte', 'profile' => [ 'title' => 'Profil', diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx index 719d16f2..25586d99 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/user-menu-content.tsx @@ -7,6 +7,7 @@ import { DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { UserInfo } from '@/components/user-info'; +import { useTranslation } from '@/helpers/i18n'; import { useMobileNavigation } from '@/hooks/use-mobile-navigation'; import { edit } from '@/routes/profile'; import type { User } from '@/types'; @@ -17,6 +18,7 @@ type Props = { export function UserMenuContent({ user }: Readonly) { const cleanup = useMobileNavigation(); + const { t } = useTranslation(); const handleLogout = () => { cleanup(); @@ -40,7 +42,7 @@ export function UserMenuContent({ user }: Readonly) { onClick={cleanup} > - Settings + {t('settings.title')} @@ -52,7 +54,7 @@ export function UserMenuContent({ user }: Readonly) { data-test="logout-button" > - Log out + {t('auth.logout')} diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 9efa2bee..abdafeb1 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -3,6 +3,7 @@ import { User, Lock, Shield, Palette } from 'lucide-react'; import type { PropsWithChildren } from 'react'; import Heading from '@/components/heading'; import { Separator } from '@/components/ui/separator'; +import { useTranslation } from '@/helpers/i18n'; import { toPath, useCurrentUrl } from '@/hooks/use-current-url'; import { cn, toUrl } from '@/lib/utils'; import { edit as editAppearance } from '@/routes/appearance'; @@ -11,30 +12,16 @@ import { show } from '@/routes/two-factor'; import { edit as editPassword } from '@/routes/user-password'; import type { NavItem } from '@/types'; -const sidebarNavItems: NavItem[] = [ - { - title: 'Mon profil', - href: edit(), - icon: User, - }, - { - title: 'Mot de passe', - href: editPassword(), - icon: Lock, - }, - { - title: 'Authentification 2FA', - href: show(), - icon: Shield, - }, - { - title: 'Apparence', - href: editAppearance(), - icon: Palette, - }, -]; - export default function SettingsLayout({ children, bare = false }: Readonly>) { + const { t } = useTranslation(); + + const sidebarNavItems: NavItem[] = [ + { title: t('settings.profile.title'), href: edit(), icon: User }, + { title: t('settings.password.title'), href: editPassword(), icon: Lock }, + { title: t('settings.two_factor.title'), href: show(), icon: Shield }, + { title: t('settings.appearance.title'), href: editAppearance(), icon: Palette }, + ]; + // Le lien le plus spécifique gagne, et une sous-page reste rattachée à son onglet. const activeHref = useCurrentUrl().findActiveHref(sidebarNavItems.map((i) => i.href)); @@ -47,8 +34,8 @@ export default function SettingsLayout({ children, bare = false }: Readonly
diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index ee194c3f..e79b522a 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -8,6 +8,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; +import { useTranslation } from '@/helpers/i18n'; import AuthLayout from '@/layouts/auth-layout'; import { store } from '@/routes/login'; import { request } from '@/routes/password'; @@ -22,12 +23,13 @@ export default function Login({ canResetPassword, }: Readonly) { const [showPassword, setShowPassword] = useState(false); + const { t } = useTranslation(); return ( - +
(
- +
- + {canResetPassword && ( - Mot de passe oublié ? + {t('auth.login.forgot')} )}
@@ -69,7 +71,7 @@ export default function Login({ name="password" required autoComplete="current-password" - placeholder="Mot de passe" + placeholder={t('auth.login.password_placeholder')} className="pr-10" />
)} From 60f73e09ee1be3e93479eb36a5015825fca96a8b Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Wed, 21 Oct 2026 11:45:11 +0000 Subject: [PATCH 03/68] feat(i18n): traduit le module tableau de bord (FR/EN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fichiers lang dashboard (fr/en) : en-tête, alertes, cartes KPI, sections, graphes, moyens de paiement, tableau impayés, section enseignant et état vide. - dashboard.tsx : tous les libellés via t(), maps de libellés (méthodes, statuts, motifs) déplacées dans le composant, fil d'Ariane et titre de page traduits. --- lang/en/dashboard.php | 143 +++++++++++++++++ lang/fr/dashboard.php | 143 +++++++++++++++++ resources/js/pages/dashboard.tsx | 263 +++++++++++++++---------------- 3 files changed, 417 insertions(+), 132 deletions(-) create mode 100644 lang/en/dashboard.php create mode 100644 lang/fr/dashboard.php diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php new file mode 100644 index 00000000..a233e1a9 --- /dev/null +++ b/lang/en/dashboard.php @@ -0,0 +1,143 @@ + 'Dashboard', + + 'header' => [ + 'school_year' => 'School year :year', + 'no_active_year' => 'No active year', + 'academic_year' => 'Academic year', + 'active_suffix' => 'active', + ], + + 'alert' => [ + 'no_payment' => ':count student(s) have made no payment', + 'no_payment_hint' => 'These students are at risk of exclusion for non-payment.', + ], + + 'kpi' => [ + 'students_enrolled' => 'Enrolled students', + 'this_week' => 'This week', + 'this_week_sub' => 'New enrollments', + 'active_students' => 'Active students', + 'active_students_sub' => 'of :total total', + 'recovery_rate' => 'Recovery rate', + 'boys' => 'Boys', + 'girls' => 'Girls', + 'gender_sub' => ':pct% of enrolled', + 'active_classes' => 'Active classes', + 'active_classes_sub' => 'Open classes', + 'users' => 'Users', + 'users_sub' => 'Accounts in total', + 'teachers' => 'Teachers', + 'teachers_sub' => 'Teaching staff', + 'rem' => 'Student/teacher ratio', + 'rem_sub' => 'STR · :count teacher(s)', + 'present_today' => "Today's attendance", + 'present_today_sub' => ':count absence(s) today', + 'presence_rate' => 'Attendance rate (today)', + 'presence_rate_sub' => ':present/:total recorded', + 'presence_rate_none' => 'No records', + 'pending_permissions' => 'Pending requests', + 'pending_permissions_sub' => 'To review', + 'documents' => 'Documents issued', + 'this_month' => 'This month', + 'official_exams' => 'Official exams', + 'official_exams_sub' => ':count registration(s)', + 'validated_bulletins' => 'Validated report cards', + 'period' => 'Period: :period', + 'all_periods' => 'All periods', + 'current_period' => 'Current period', + 'general_average' => 'Overall average', + 'total_billed' => 'Total billed', + 'total_billed_sub' => ':count invoices', + 'collected' => 'Collected', + 'collected_sub' => 'Rate :pct%', + 'remaining' => 'Outstanding', + 'remaining_sub' => ':count open files', + 'paid_unpaid' => 'Paid / Unpaid', + 'paid_unpaid_sub' => ':count partial payments', + 'collected_month' => 'Collected (month)', + 'expenses_month' => 'Expenses (month)', + 'net_month' => 'Net balance (month)', + 'surplus' => 'Surplus', + 'deficit' => 'Deficit', + ], + + 'section' => [ + 'pending_permissions' => 'Pending requests', + 'pending_permissions_action' => 'Requests', + 'pending_permissions_empty' => 'No pending request', + 'upcoming_exams' => 'Upcoming official exams', + 'upcoming_exams_action' => 'Exams', + 'upcoming_exams_empty' => 'No scheduled exam', + 'registered' => ':count registered', + 'monthly' => 'Collections — last 6 months', + 'monthly_empty' => 'No payment in the period', + 'collected_tooltip' => 'Collected', + 'cash_balances' => 'Cash balances', + 'cash_balances_empty' => 'No active cash account', + 'total' => 'Total', + 'payment_methods' => 'Payment methods', + 'payment_methods_empty' => 'No collection in the period', + 'invoice_breakdown' => 'Invoice breakdown', + 'students_per_class' => 'Students per class', + 'students_tooltip' => 'Students', + 'recent_payments' => 'Recent payments', + 'recent_payments_action' => 'Accounting', + 'recent_payments_empty' => 'No payment recorded', + 'recent_enrollments' => 'Recent enrollments', + 'recent_enrollments_action' => 'Enrollments', + 'recent_enrollments_empty' => 'No enrollment for this year', + 'payment_methods_year' => 'Payment methods — :year', + 'students_no_pay' => 'At-risk students — no payment', + 'students_no_pay_action' => 'View all', + 'col_student' => 'Student', + 'col_class' => 'Class', + 'col_amount_due' => 'Amount due', + 'invoice_action' => 'Invoice', + ], + + 'teacher' => [ + 'today_timetable' => 'My timetable — today', + 'timetable_action' => 'Timetable', + 'today_empty' => 'No class scheduled today.', + 'room' => 'room :room', + 'pending_marks' => 'Marks to enter', + 'evaluations_action' => 'Evaluations', + 'pending_marks_empty' => 'All your evaluations are entered. 🎉', + 'my_classes' => 'My classes & subjects', + 'assignments_action' => 'Assignments', + 'assignments_empty' => 'No assignment for year :year', + 'take_attendance' => 'Take attendance', + ], + + 'empty' => [ + 'title' => 'Dashboard not available', + 'subtitle' => 'No data to display for your profile.', + ], + + 'method' => [ + 'cash' => 'Cash', + 'mobile_money' => 'Mobile Money', + 'bank_transfer' => 'Bank transfer', + 'bank_transfer_short' => 'Transfer', + 'cheque' => 'Cheque', + 'bank' => 'Bank', + 'other' => 'Other', + ], + + 'status' => [ + 'issued' => 'Unpaid', + 'partially_paid' => 'Partial', + 'paid' => 'Paid', + 'active' => 'Active', + 'pending' => 'Pending', + ], + + 'reason' => [ + 'medical' => 'Medical', + 'familial' => 'Family', + 'autre' => 'Other', + ], +]; diff --git a/lang/fr/dashboard.php b/lang/fr/dashboard.php new file mode 100644 index 00000000..c4156d41 --- /dev/null +++ b/lang/fr/dashboard.php @@ -0,0 +1,143 @@ + 'Tableau de bord', + + 'header' => [ + 'school_year' => 'Année scolaire :year', + 'no_active_year' => 'Aucune année active', + 'academic_year' => 'Année académique', + 'active_suffix' => 'active', + ], + + 'alert' => [ + 'no_payment' => ":count élève(s) n'ont effectué aucun paiement", + 'no_payment_hint' => "Ces élèves sont à risque d'exclusion faute de paiement.", + ], + + 'kpi' => [ + 'students_enrolled' => 'Élèves inscrits', + 'this_week' => 'Cette semaine', + 'this_week_sub' => 'Nouvelles inscriptions', + 'active_students' => 'Élèves actifs', + 'active_students_sub' => 'sur :total au total', + 'recovery_rate' => 'Taux de recouvrement', + 'boys' => 'Garçons', + 'girls' => 'Filles', + 'gender_sub' => ':pct% des inscrits', + 'active_classes' => 'Classes actives', + 'active_classes_sub' => 'Classes ouvertes', + 'users' => 'Utilisateurs', + 'users_sub' => 'Comptes au total', + 'teachers' => 'Enseignants', + 'teachers_sub' => 'Personnel pédagogique', + 'rem' => 'Ratio élèves/enseignant', + 'rem_sub' => 'REM · :count enseignant(s)', + 'present_today' => 'Présences du jour', + 'present_today_sub' => ":count absence(s) aujourd'hui", + 'presence_rate' => 'Taux de présence (jour)', + 'presence_rate_sub' => ':present/:total pointés', + 'presence_rate_none' => 'Aucun pointage', + 'pending_permissions' => 'Permissions en attente', + 'pending_permissions_sub' => 'À traiter', + 'documents' => 'Documents délivrés', + 'this_month' => 'Ce mois-ci', + 'official_exams' => 'Examens officiels', + 'official_exams_sub' => ':count inscription(s)', + 'validated_bulletins' => 'Bulletins validés', + 'period' => 'Période : :period', + 'all_periods' => 'Toutes périodes', + 'current_period' => 'Période courante', + 'general_average' => 'Moyenne générale', + 'total_billed' => 'Total facturé', + 'total_billed_sub' => ':count factures', + 'collected' => 'Encaissé', + 'collected_sub' => 'Taux :pct%', + 'remaining' => 'Reste à recouvrer', + 'remaining_sub' => ':count dossiers ouverts', + 'paid_unpaid' => 'Soldés / Impayés', + 'paid_unpaid_sub' => ':count paiements partiels', + 'collected_month' => 'Encaissé (mois)', + 'expenses_month' => 'Dépenses (mois)', + 'net_month' => 'Solde net (mois)', + 'surplus' => 'Excédent', + 'deficit' => 'Déficit', + ], + + 'section' => [ + 'pending_permissions' => 'Permissions en attente', + 'pending_permissions_action' => 'Permissions', + 'pending_permissions_empty' => 'Aucune demande en attente', + 'upcoming_exams' => 'Prochains examens officiels', + 'upcoming_exams_action' => 'Examens', + 'upcoming_exams_empty' => 'Aucun examen planifié', + 'registered' => ':count inscrit(s)', + 'monthly' => 'Encaissements — 6 derniers mois', + 'monthly_empty' => 'Aucun paiement sur la période', + 'collected_tooltip' => 'Encaissé', + 'cash_balances' => 'Soldes des caisses', + 'cash_balances_empty' => 'Aucune caisse active', + 'total' => 'Total', + 'payment_methods' => 'Moyens de paiement', + 'payment_methods_empty' => 'Aucun encaissement sur la période', + 'invoice_breakdown' => 'Répartition des factures', + 'students_per_class' => 'Élèves par classe', + 'students_tooltip' => 'Élèves', + 'recent_payments' => 'Derniers paiements', + 'recent_payments_action' => 'Comptabilité', + 'recent_payments_empty' => 'Aucun paiement enregistré', + 'recent_enrollments' => 'Dernières inscriptions', + 'recent_enrollments_action' => 'Inscriptions', + 'recent_enrollments_empty' => 'Aucune inscription pour cette année', + 'payment_methods_year' => 'Moyens de paiement — :year', + 'students_no_pay' => 'Élèves à risque — aucun paiement', + 'students_no_pay_action' => 'Voir tout', + 'col_student' => 'Élève', + 'col_class' => 'Classe', + 'col_amount_due' => 'Montant dû', + 'invoice_action' => 'Facture', + ], + + 'teacher' => [ + 'today_timetable' => "Mon emploi du temps — aujourd'hui", + 'timetable_action' => 'Emploi du temps', + 'today_empty' => "Aucun cours programmé aujourd'hui.", + 'room' => 'salle :room', + 'pending_marks' => 'Notes à saisir', + 'evaluations_action' => 'Évaluations', + 'pending_marks_empty' => 'Toutes vos évaluations sont saisies. 🎉', + 'my_classes' => 'Mes classes & matières', + 'assignments_action' => 'Affectations', + 'assignments_empty' => "Aucune affectation pour l'année :year", + 'take_attendance' => "Faire l'appel", + ], + + 'empty' => [ + 'title' => 'Tableau de bord non disponible', + 'subtitle' => 'Aucune donnée à afficher pour votre profil.', + ], + + 'method' => [ + 'cash' => 'Espèces', + 'mobile_money' => 'Mobile Money', + 'bank_transfer' => 'Virement bancaire', + 'bank_transfer_short' => 'Virement', + 'cheque' => 'Chèque', + 'bank' => 'Banque', + 'other' => 'Autre', + ], + + 'status' => [ + 'issued' => 'Non payé', + 'partially_paid' => 'Partiel', + 'paid' => 'Soldé', + 'active' => 'Actif', + 'pending' => 'En attente', + ], + + 'reason' => [ + 'medical' => 'Médical', + 'familial' => 'Familial', + 'autre' => 'Autre', + ], +]; diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 0cc64ffa..e1068ef5 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -7,6 +7,7 @@ import { import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useMoney } from '@/helpers/money'; +import { useTranslation } from '@/helpers/i18n'; import { route } from '@/helpers/route'; import AppLayout from '@/layouts/app-layout'; import { useChartTheme } from '@/lib/chart-theme'; @@ -161,38 +162,9 @@ interface DashboardProps { /* ------------------------------------------------------------------ */ -const PAYMENT_METHOD_LABELS: Record = { - CASH: 'Espèces', - MOBILE_MONEY: 'Mobile Money', - BANK_TRANSFER: 'Virement bancaire', - CHEQUE: 'Chèque', - AUTRE: 'Autre', -}; - const pct = (paid: number, total: number) => total > 0 ? Math.min(100, Math.round((paid / total) * 100)) : 0; -const methodLabel: Record = { - CASH: 'Espèces', - MOBILE_MONEY: 'Mobile Money', - BANK_TRANSFER: 'Virement', - CHEQUE: 'Chèque', -}; - -const cashTypeLabel: Record = { - CASH: 'Espèces', - MOBILE_MONEY: 'Mobile Money', - BANK: 'Banque', -}; - -const statusLabel: Record = { - ISSUED: { label: 'Non payé', cls: 'bg-red-100 text-red-700' }, - PARTIALLY_PAID: { label: 'Partiel', cls: 'bg-orange-100 text-orange-700' }, - PAID: { label: 'Soldé', cls: 'bg-green-100 text-green-700' }, - ACTIVE: { label: 'Actif', cls: 'bg-green-100 text-green-700' }, - PENDING: { label: 'En attente',cls: 'bg-gray-100 text-gray-600' }, -}; - /* ------------------------------------------------------------------ */ /* Micro-composants */ /* ------------------------------------------------------------------ */ @@ -271,11 +243,38 @@ function SectionCard({ title, icon, count, children, action }: { /* Composant principal */ /* ------------------------------------------------------------------ */ -const breadcrumbs: BreadcrumbItem[] = [{ title: 'Tableau de bord', href: '/dashboard' }]; - export default function Dashboard({ activeYear, selectedYearId, selectedYear, academicYears, userRole, financial, enrollments, teaching, academic, bulletins }: Readonly) { const theme = useChartTheme(); const fmt = useMoney(); + const { t } = useTranslation(); + + const breadcrumbs: BreadcrumbItem[] = [{ title: t('dashboard.title'), href: '/dashboard' }]; + + const PAYMENT_METHOD_LABELS: Record = { + CASH: t('dashboard.method.cash'), + MOBILE_MONEY: t('dashboard.method.mobile_money'), + BANK_TRANSFER: t('dashboard.method.bank_transfer'), + CHEQUE: t('dashboard.method.cheque'), + AUTRE: t('dashboard.method.other'), + }; + const methodLabel: Record = { + CASH: t('dashboard.method.cash'), + MOBILE_MONEY: t('dashboard.method.mobile_money'), + BANK_TRANSFER: t('dashboard.method.bank_transfer_short'), + CHEQUE: t('dashboard.method.cheque'), + }; + const cashTypeLabel: Record = { + CASH: t('dashboard.method.cash'), + MOBILE_MONEY: t('dashboard.method.mobile_money'), + BANK: t('dashboard.method.bank'), + }; + const statusLabel: Record = { + ISSUED: { label: t('dashboard.status.issued'), cls: 'bg-red-100 text-red-700' }, + PARTIALLY_PAID: { label: t('dashboard.status.partially_paid'), cls: 'bg-orange-100 text-orange-700' }, + PAID: { label: t('dashboard.status.paid'), cls: 'bg-green-100 text-green-700' }, + ACTIVE: { label: t('dashboard.status.active'), cls: 'bg-green-100 text-green-700' }, + PENDING: { label: t('dashboard.status.pending'), cls: 'bg-gray-100 text-gray-600' }, + }; const changeYear = (id: string) => { router.get(route('dashboard'), { academic_year_id: id }, { preserveScroll: true, preserveState: false }); @@ -285,9 +284,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: 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 }, + { name: t('dashboard.status.paid'), value: Number(stats.paid_count ?? 0), color: theme.status.good }, + { name: t('dashboard.status.partially_paid'), value: Number(stats.partial_count ?? 0), color: theme.status.warning }, + { name: t('dashboard.status.issued'), value: Number(stats.issued_count ?? 0), color: theme.status.critical }, ].filter(d => d.value > 0) : []; const isFinancial = !!financial; @@ -295,7 +294,7 @@ export default function Dashboard({ activeYear, selectedYearId, selectedYear, ac const isTeacher = !!teaching; const isAcademic = !!academic; const isBulletins = !!bulletins; - const reasonLabel: Record = { medical: 'Médical', familial: 'Familial', autre: 'Autre' }; + const reasonLabel: Record = { medical: t('dashboard.reason.medical'), familial: t('dashboard.reason.familial'), autre: t('dashboard.reason.autre') }; // Taux de présence du jour (présents / pointés), calculé depuis les compteurs existants. const pointedToday = academic ? academic.present_today + academic.absent_today : 0; @@ -307,16 +306,16 @@ export default function Dashboard({ activeYear, selectedYearId, selectedYear, ac return ( - +
{/* ── En-tête ──────────────────────────────────────────── */}
-

Tableau de bord

+

{t('dashboard.title')}

- {selectedYear ? `Année scolaire ${selectedYear.year}` : 'Aucune année active'} + {selectedYear ? t('dashboard.header.school_year', { year: selectedYear.year }) : t('dashboard.header.no_active_year')} {userRole && ` · ${userRole}`}

@@ -324,12 +323,12 @@ export default function Dashboard({ activeYear, selectedYearId, selectedYear, ac setBulkAction(event.target.value)} className="h-10 rounded-md border border-blue-200 bg-white px-3 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" > - - - + + +
@@ -373,7 +370,7 @@ export default function Index({ students, perPage, stats, filters, options }: Re setSearchQuery(event.target.value)} onKeyDown={(event) => event.key === 'Enter' && handleSearch()} @@ -382,7 +379,7 @@ export default function Index({ students, perPage, stats, filters, options }: Re
setNationalityFilter(event.target.value)} onKeyDown={(event) => event.key === 'Enter' && handleSearch()} @@ -390,7 +387,7 @@ export default function Index({ students, perPage, stats, filters, options }: Re /> )}
@@ -427,7 +424,7 @@ export default function Index({ students, perPage, stats, filters, options }: Re onChange={(e) => go({ class_id: e.target.value })} className={FILTER_CLS} > - + {options.classrooms.map((c) => )} {options.regions.length > 0 && ( )} {options.prefectures.length > 0 && ( )} {/* Tri */}
- Trier : + {t('students.sort.label')}
@@ -493,10 +490,10 @@ export default function Index({ students, perPage, stats, filters, options }: Re {/* Table header: count + per-page selector */}

- {students.total} élève(s) + {students.total} {t('students.table.unit')}

- Lignes par page : + {t('students.table.per_page')} setPortalPwd(e.target.value)} placeholder="Mot de passe (min 8)" className="w-52 bg-white" /> + setPortalPwd(e.target.value)} placeholder={t('students.show.portal_password')} className="w-52 bg-white" /> {student.portal_active && ( )}
@@ -343,35 +346,35 @@ export default function Show({ student, documentContext, issuedDocuments, curren

- Identité + {t('students.show.identity')}

-

Nom complet

+

{t('students.field.full_name')}

{student.firstname} {student.lastname}

-

Matricule

+

{t('students.field.matricule')}

{student.matricule ?? '—'}

-

Genre

-

{student.gender === 'male' ? 'Masculin' : 'Féminin'}

+

{t('students.field.gender')}

+

{student.gender === 'male' ? t('students.gender.male') : t('students.gender.female')}

-

Statut

+

{t('students.field.status')}

- {student.active ? 'Actif' : 'Inactif'} + {student.active ? t('common.status.active') : t('common.status.inactive')}

-

Date de naissance

-

{formatDate(student.birth_date)}

+

{t('students.field.birth_date')}

+

{formatDate(student.birth_date, dateLocale)}

-

Lieu de naissance

+

{t('students.field.place_of_birth')}

{student.place_of_birth ?? '—'}

@@ -380,35 +383,35 @@ export default function Show({ student, documentContext, issuedDocuments, curren

- Contact & compte + {t('students.show.contact_account')}

-

Compte utilisateur

+

{t('students.field.user_account')}

{userLabel}

-

Email compte

+

{t('students.field.account_email')}

{student.user?.email ?? '—'}

-

Téléphone

+

{t('students.field.phone')}

{student.phone ?? '—'}

-

Email élève

+

{t('students.field.student_email')}

{student.email ?? '—'}

-

Ville

+

{t('students.field.city')}

{student.city ?? '—'}

-

Nationalité

+

{t('students.field.nationality')}

{student.nationality ?? '—'}

-

Adresse

+

{t('students.field.address')}

{student.address ?? '—'}

@@ -417,23 +420,23 @@ export default function Show({ student, documentContext, issuedDocuments, curren

- Dossier administratif + {t('students.show.admin_file')}

-

Type d'admission

+

{t('students.field.admission_type')}

{admissionTypeLabel[student.information?.admission_type ?? 'new']}

-

N° acte de naissance

+

{t('students.field.birth_cert_number')}

{student.information?.birth_certificate_number ?? '—'}

-

Date de délivrance

-

{formatDate(student.information?.birth_certificate_issue_date)}

+

{t('students.field.issue_date')}

+

{formatDate(student.information?.birth_certificate_issue_date, dateLocale)}

-

Lieu de délivrance

+

{t('students.field.issue_place')}

{student.information?.birth_certificate_issue_place ?? '—'}

@@ -442,49 +445,49 @@ export default function Show({ student, documentContext, issuedDocuments, curren

- Parents & médical + {t('students.show.parents_medical')}

{hasEmergencyContact && (
- Contact d'urgence disponible + {t('students.show.emergency_available')}
)}
-

Père

+

{t('students.field.father')}

{student.parent_info?.father_firstname ?? '—'} {student.parent_info?.father_lastname ?? ''}

-

Mère

+

{t('students.field.mother')}

{student.parent_info?.mother_firstname ?? '—'} {student.parent_info?.mother_lastname ?? ''}

-

Téléphone père

+

{t('students.field.father_phone')}

{student.parent_info?.father_phone ?? '—'}

-

Téléphone mère

+

{t('students.field.mother_phone')}

{student.parent_info?.mother_phone ?? '—'}

-

Email parent

+

{t('students.field.parent_email')}

{student.parent_info?.email ?? '—'}

-

Groupe sanguin

+

{t('students.field.blood_group')}

{student.medical_info?.blood_group ?? '—'}

-

Contact d'urgence

+

{t('students.field.emergency_contact')}

{student.medical_info?.emergency_contact_name ?? '—'}

-

Téléphone d'urgence

+

{t('students.field.emergency_phone')}

{student.medical_info?.emergency_contact_phone ?? '—'}

-

Allergies

+

{t('students.field.allergies')}

{student.medical_info?.allergies ?? '—'}

@@ -495,18 +498,18 @@ export default function Show({ student, documentContext, issuedDocuments, curren

- Pièces du dossier + {t('students.show.docs_title')} ({documents.length})

{/* Formulaire d'ajout : nom + fichier */}
- - setDocName(e.target.value)} placeholder="Ex: Extrait de naissance, Passeport..." className="bg-white" /> + + setDocName(e.target.value)} placeholder={t('students.show.doc_name_ph')} className="bg-white" />
- +
-

Stockés dans le dossier privé de l'élève. PDF, image ou Word — max 5 Mo.

+

{t('students.show.docs_hint')}

{documents.length === 0 ? ( -

Aucune pièce dans le dossier.

+

{t('students.show.docs_empty')}

) : (
{documents.map(d => ( @@ -536,7 +539,7 @@ export default function Show({ student, documentContext, issuedDocuments, curren
- +
- Créé le {new Date(student.created_at).toLocaleString('fr-FR')} - Mis à jour le {new Date(student.updated_at).toLocaleString('fr-FR')} + {t('students.show.created_at', { date: new Date(student.created_at).toLocaleString(dateLocale) })} + {t('students.show.updated_at', { date: new Date(student.updated_at).toLocaleString(dateLocale) })}
@@ -588,20 +591,20 @@ export default function Show({ student, documentContext, issuedDocuments, curren - Délivrer un document + {t('students.show.deliver_document')} - Générez un certificat ou une attestation pour {student.firstname} {student.lastname}. + {t('students.dialog.deliver_desc', { name: `${student.firstname} ${student.lastname}` })}
- + @@ -609,20 +612,20 @@ export default function Show({ student, documentContext, issuedDocuments, curren
- - setClasse(e.target.value)} placeholder="Ex: 3ème A" /> + + setClasse(e.target.value)} placeholder={t('students.dialog.class_ph')} />
- + setAnnee(e.target.value)} placeholder="2025-2026" />
- +
@@ -632,16 +635,16 @@ export default function Show({ student, documentContext, issuedDocuments, curren - Changer de classe + {t('students.show.change_class')} - Réaffecter {student.firstname} {student.lastname} pour l'année {currentEnrollment?.year}. + {t('students.dialog.change_class_desc', { name: `${student.firstname} ${student.lastname}`, year: currentEnrollment?.year ?? '—' })}
- +
-

Les frais déjà facturés ne sont pas recalculés automatiquement.

+

{t('students.dialog.reassign_hint')}

- +
From 8edc58780954ac4fb74da97b0baa366ed7693fe3 Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Wed, 21 Oct 2026 15:49:57 +0000 Subject: [PATCH 05/68] =?UTF-8?q?feat(i18n):=20traduit=20le=20formulaire?= =?UTF-8?q?=20=C3=A9l=C3=A8ve=20par=20=C3=A9tapes=20(FR/EN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Groupe lang students.form (fr/en) : titres create/edit, libellés d'étapes, sections, champs, placeholders, photo, et récapitulatif. - student-form-steps.tsx : tous les libellés, étapes, sections, boutons et récapitulatif via t() ; genre/admission/statut traduits. - Create.tsx / Edit.tsx : en-têtes et titres de page via t(). --- lang/en/students.php | 112 ++++++++++++ lang/fr/students.php | 112 ++++++++++++ .../students/student-form-steps.tsx | 162 +++++++++--------- resources/js/pages/Eleves/Students/Create.tsx | 8 +- resources/js/pages/Eleves/Students/Edit.tsx | 8 +- 5 files changed, 316 insertions(+), 86 deletions(-) diff --git a/lang/en/students.php b/lang/en/students.php index bace8011..0e8ea630 100644 --- a/lang/en/students.php +++ b/lang/en/students.php @@ -176,4 +176,116 @@ 'reassign_hint' => 'Fees already invoiced are not recalculated automatically.', 'reassign' => 'Reassign', ], + + 'form' => [ + 'create_title' => 'Add a student', + 'create_subtitle' => 'Follow the steps to add a new student.', + 'edit_title' => 'Edit a student', + 'edit_subtitle' => 'Update the information through the same step-by-step form.', + 'edit_head' => 'Edit student', + 'adding' => 'Adding...', + 'saving' => 'Saving...', + 'step' => 'Step :n', + 'select' => 'Select', + 'photo_selected' => 'Selected: :name', + 'photo_hint' => 'Image (JPG, PNG…), 4 MB max.', + 'photo_hint_keep' => ' Leave empty to keep the current photo.', + 'summary_hint' => 'Review the information before adding the student.', + 'photo_kept' => 'Current photo kept', + 'photo_none' => 'None', + + 'steps' => [ + 'student' => 'Student information', + 'admin' => 'Administrative file', + 'parents' => 'Parents', + 'health' => 'Health', + 'summary' => 'Summary', + ], + + 'sec' => [ + 'identity' => 'Student identity', + 'birth' => 'Birth', + 'contact' => 'Contact details', + 'photo_status' => 'Photo and status', + 'admission' => 'Admission type', + 'birth_cert' => 'Birth certificate', + 'father' => "Father's information", + 'mother' => "Mother's information", + 'parents_contact' => 'Parents contact', + 'medical' => 'Medical information', + 'emergency' => 'Emergency contact', + 'allergies' => 'Allergies and sensitivities', + 'summary' => 'Summary', + ], + + 'lbl' => [ + 'matricule' => 'ID number', + 'gender' => 'Gender *', + 'firstname' => 'First name *', + 'lastname' => 'Last name *', + 'birth_date' => 'Date of birth *', + 'place_of_birth' => 'Place of birth', + 'nationality' => 'Nationality', + 'phone' => 'Phone', + 'email' => 'Email', + 'city' => 'City', + 'region' => 'Region', + 'prefecture' => 'Prefecture', + 'address' => 'Address', + 'profile_photo' => 'Profile photo', + 'active' => 'Active student', + 'admission_type' => 'Admission type *', + 'birth_cert_number' => 'Birth certificate no.', + 'issue_date' => 'Issue date', + 'issue_place' => 'Issue place', + 'father_firstname' => "Father's first name *", + 'father_lastname' => "Father's last name *", + 'father_profession' => "Father's occupation", + 'father_phone' => "Father's phone", + 'mother_firstname' => "Mother's first name *", + 'mother_lastname' => "Mother's last name *", + 'mother_profession' => "Mother's occupation", + 'mother_phone' => "Mother's phone", + 'parent_email' => 'Parent/guardian email', + 'blood_group' => 'Blood group', + 'vaccinations' => 'Vaccinations', + 'emergency_contact' => 'Emergency contact', + 'emergency_phone' => 'Emergency phone', + 'allergies' => 'Allergies', + ], + + 'recap' => [ + 'identity' => 'Identity', + 'birth' => 'Birth', + 'origin_contact' => 'Origin & contact', + 'admin' => 'Administrative file', + 'parents' => 'Parents', + 'health' => 'Health', + 'photo_status' => 'Photo & status', + 'matricule' => 'ID number', + 'lastname' => 'Last name', + 'firstname' => 'First name', + 'gender' => 'Gender', + 'date' => 'Date', + 'place' => 'Place', + 'region' => 'Region', + 'prefecture' => 'Prefecture', + 'city' => 'City', + 'address' => 'Address', + 'nationality' => 'Nationality', + 'phone' => 'Phone', + 'email' => 'Email', + 'admission_type' => 'Admission type', + 'birth_cert' => 'Birth certificate no.', + 'father' => 'Father', + 'father_phone' => 'Father phone', + 'mother' => 'Mother', + 'mother_phone' => 'Mother phone', + 'blood_group' => 'Blood group', + 'emergency' => 'Emergency contact', + 'emergency_phone' => 'Emergency phone', + 'photo' => 'Photo', + 'status' => 'Status', + ], + ], ]; diff --git a/lang/fr/students.php b/lang/fr/students.php index 0a252a1f..2282ec4b 100644 --- a/lang/fr/students.php +++ b/lang/fr/students.php @@ -176,4 +176,116 @@ 'reassign_hint' => 'Les frais déjà facturés ne sont pas recalculés automatiquement.', 'reassign' => 'Réaffecter', ], + + 'form' => [ + 'create_title' => 'Ajouter un élève', + 'create_subtitle' => 'Suivez les étapes pour ajouter un nouvel élève.', + 'edit_title' => 'Modifier un élève', + 'edit_subtitle' => 'Mettez à jour les informations via le même formulaire par étapes.', + 'edit_head' => 'Modifier élève', + 'adding' => 'Ajout...', + 'saving' => 'Enregistrement...', + 'step' => 'Étape :n', + 'select' => 'Sélectionner', + 'photo_selected' => 'Sélectionné : :name', + 'photo_hint' => 'Image (JPG, PNG…), 4 Mo max.', + 'photo_hint_keep' => ' Laissez vide pour conserver la photo actuelle.', + 'summary_hint' => "Vérifiez les informations avant d'ajouter l'élève.", + 'photo_kept' => 'Photo actuelle conservée', + 'photo_none' => 'Aucune', + + 'steps' => [ + 'student' => "Informations de l'élève", + 'admin' => 'Dossier administratif', + 'parents' => 'Parents', + 'health' => 'Santé', + 'summary' => 'Récapitulatif', + ], + + 'sec' => [ + 'identity' => "Identité de l'élève", + 'birth' => 'Naissance', + 'contact' => 'Coordonnées', + 'photo_status' => 'Photo et statut', + 'admission' => "Type d'admission", + 'birth_cert' => 'Acte de naissance', + 'father' => 'Informations du père', + 'mother' => 'Informations de la mère', + 'parents_contact' => 'Contact des parents', + 'medical' => 'Informations médicales', + 'emergency' => "Contact d'urgence", + 'allergies' => 'Allergies et sensibilités', + 'summary' => 'Récapitulatif', + ], + + 'lbl' => [ + 'matricule' => 'Matricule', + 'gender' => 'Genre *', + 'firstname' => 'Prénom *', + 'lastname' => 'Nom *', + 'birth_date' => 'Date de naissance *', + 'place_of_birth' => 'Lieu de naissance', + 'nationality' => 'Nationalité', + 'phone' => 'Téléphone', + 'email' => 'Email', + 'city' => 'Ville', + 'region' => 'Région', + 'prefecture' => 'Préfecture', + 'address' => 'Adresse', + 'profile_photo' => 'Photo de profil', + 'active' => 'Élève actif', + 'admission_type' => "Type d'admission *", + 'birth_cert_number' => 'N° acte de naissance', + 'issue_date' => 'Date de délivrance', + 'issue_place' => 'Lieu de délivrance', + 'father_firstname' => 'Prénom du père *', + 'father_lastname' => 'Nom du père *', + 'father_profession' => 'Profession du père', + 'father_phone' => 'Téléphone du père', + 'mother_firstname' => 'Prénom de la mère *', + 'mother_lastname' => 'Nom de la mère *', + 'mother_profession' => 'Profession de la mère', + 'mother_phone' => 'Téléphone de la mère', + 'parent_email' => 'Email parent/tuteur', + 'blood_group' => 'Groupe sanguin', + 'vaccinations' => 'Vaccinations', + 'emergency_contact' => "Contact d'urgence", + 'emergency_phone' => "Téléphone d'urgence", + 'allergies' => 'Allergies', + ], + + 'recap' => [ + 'identity' => 'Identité', + 'birth' => 'Naissance', + 'origin_contact' => 'Origine & contact', + 'admin' => 'Dossier administratif', + 'parents' => 'Parents', + 'health' => 'Santé', + 'photo_status' => 'Photo & statut', + 'matricule' => 'Matricule', + 'lastname' => 'Nom', + 'firstname' => 'Prénom', + 'gender' => 'Genre', + 'date' => 'Date', + 'place' => 'Lieu', + 'region' => 'Région', + 'prefecture' => 'Préfecture', + 'city' => 'Ville', + 'address' => 'Adresse', + 'nationality' => 'Nationalité', + 'phone' => 'Téléphone', + 'email' => 'E-mail', + 'admission_type' => "Type d'admission", + 'birth_cert' => 'N° extrait de naissance', + 'father' => 'Père', + 'father_phone' => 'Tél. père', + 'mother' => 'Mère', + 'mother_phone' => 'Tél. mère', + 'blood_group' => 'Groupe sanguin', + 'emergency' => "Contact d'urgence", + 'emergency_phone' => 'Tél. urgence', + 'photo' => 'Photo', + 'status' => 'Statut', + ], + ], ]; diff --git a/resources/js/components/students/student-form-steps.tsx b/resources/js/components/students/student-form-steps.tsx index 20710fa1..6efcaa34 100644 --- a/resources/js/components/students/student-form-steps.tsx +++ b/resources/js/components/students/student-form-steps.tsx @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { TOGO_REGIONS, prefecturesOf } from '@/data/togo'; +import { useTranslation } from '@/helpers/i18n'; const selectCls = 'flex h-10 w-full rounded-md border border-gray-200 bg-white px-3 text-sm focus:outline-none focus:ring-2 focus:ring-violet-400'; @@ -72,14 +73,6 @@ interface StudentFormStepsProps { onSubmit: (data: StudentFormPayload) => void; } -const stepLabels = [ - "Informations de l'élève", - 'Dossier administratif', - 'Parents', - 'Santé', - 'Récapitulatif', -]; - export function StudentFormSteps({ mode, initialValues, @@ -88,16 +81,25 @@ export function StudentFormSteps({ onCancel, onSubmit, }: Readonly) { + const { t } = useTranslation(); const [currentStep, setCurrentStep] = useState(0); const [formData, setFormData] = useState(initialValues); + const stepLabels = [ + t('students.form.steps.student'), + t('students.form.steps.admin'), + t('students.form.steps.parents'), + t('students.form.steps.health'), + t('students.form.steps.summary'), + ]; + const buttonLabel = useMemo(() => { if (mode === 'create') { - return isSubmitting ? 'Ajout...' : 'Ajouter'; + return isSubmitting ? t('students.form.adding') : t('common.actions.add'); } - return isSubmitting ? 'Enregistrement...' : 'Enregistrer'; - }, [mode, isSubmitting]); + return isSubmitting ? t('students.form.saving') : t('common.actions.save'); + }, [mode, isSubmitting, t]); const setField = (field: K, value: StudentFormPayload[K]) => { setFormData((prev) => ({ ...prev, [field]: value })); @@ -175,7 +177,7 @@ export function StudentFormSteps({ onClick={() => setCurrentStep(index)} className={`rounded-lg px-4 py-3 text-left transition-colors ${buttonClasses}`} > -

Étape {index + 1}

+

{t('students.form.step', { n: index + 1 })}

{label}

); @@ -192,11 +194,11 @@ export function StudentFormSteps({
-

Identité de l'élève

+

{t('students.form.sec.identity')}

- +
- + {getError('gender') &&

{getError('gender')}

}
- +
- +
-

Naissance

+

{t('students.form.sec.birth')}

- +
- +
-

Coordonnées

+

{t('students.form.sec.contact')}

-

Photo et statut

+

{t('students.form.sec.photo_status')}

- + {formData.profile_photo instanceof File ? ( -

Sélectionné : {formData.profile_photo.name}

+

{t('students.form.photo_selected', { name: formData.profile_photo.name })}

) : (

- Image (JPG, PNG…), 4 Mo max.{mode === 'edit' ? ' Laissez vide pour conserver la photo actuelle.' : ''} + {t('students.form.photo_hint')}{mode === 'edit' ? t('students.form.photo_hint_keep') : ''}

)} {getError('profile_photo') &&

{getError('profile_photo')}

} @@ -421,7 +423,7 @@ export function StudentFormSteps({ checked={formData.active} onCheckedChange={(checked) => setField('active', checked === true)} /> - +
@@ -436,19 +438,19 @@ export function StudentFormSteps({
-

Type d'admission

+

{t('students.form.sec.admission')}

- + {getError('information.admission_type') &&

{getError('information.admission_type')}

}
@@ -460,11 +462,11 @@ export function StudentFormSteps({
-

Acte de naissance

+

{t('students.form.sec.birth_cert')}

- +
- +
- +
-

Informations du père

+

{t('students.form.sec.father')}

- + {getError('parent.father_firstname')}

}
- + {getError('parent.father_lastname')}

}
- +
-

Informations de la mère

+

{t('students.form.sec.mother')}

- + {getError('parent.mother_firstname')}

}
- + {getError('parent.mother_lastname')}

}
- +
-

Contact des parents

+

{t('students.form.sec.parents_contact')}

-

Informations médicales

+

{t('students.form.sec.medical')}

-

Contact d'urgence

+

{t('students.form.sec.emergency')}

- +
-

Allergies et sensibilités

+

{t('students.form.sec.allergies')}

- +