30 Commits

Author SHA1 Message Date
yann64 fbe184d2e6 Fix export type hint, dark mode import page, documentation
- Corrige le type de retour de UserController::export() (StreamedResponse)
- Ajoute les classes dark mode manquantes sur le bloc info de la page import
- Génère la documentation complète du projet dans docs/documentation.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 04:13:47 +02:00
yann64 e835bab7df Fix paramètres de route utilisateur et z-index carte
- Vues admin/utilisateurs : route() utilisait $user sans clé nommée ;
  Laravel ne résout pas automatiquement un modèle vers un paramètre
  {utilisateur} (nom non-anglais) — remplacé par ['utilisateur' => $user]
  dans edit.blade.php, index.blade.php et UserController::store()
- Carte : ajout de position:relative + z-index:0 sur #carte-map pour
  créer un contexte d'empilement qui confine les z-indexes internes de
  Leaflet (≤800) et laisse le menu (z-index:40) s'afficher par-dessus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 03:56:41 +02:00
yann64 6a73a2f001 Gestion utilisateurs, limites recherche, filtres lieux/sources, fix logo prod
- Admin : CRUD complet utilisateurs (créer, modifier nom/email/mdp/rôle, supprimer)
  avec garde-fous (dernier admin, compte propre)
- Recherche : limite configurable par l'admin (défaut 200), bannière d'avertissement
  quand la limite est atteinte, plus de pagination (résultats en bloc)
- Lieux : liste non chargée sans filtre actif (performance sur grands volumes)
- Sources : idem pour admin/responsables ; membres voient toujours leurs sources
- Logo 404 prod : +FollowSymLinks dans .htaccess, storage:link dans l'assistant
  d'installation, bouton "Recréer le lien" dans Administration → Paramètres

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 03:39:06 +02:00
yann64 dab9e758fe bump version 1.1.0 2026-06-06 08:29:50 +02:00
yann64 7c6e72760b Release 1.0.5 — changelog + bump version dev 1.0.6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 20:15:49 +02:00
yann64 aaf0fc2cd9 Import/export CSV des lieux
Export (GET lieux/export/csv) :
- Colonnes : nom, code, type, lieu_parent (nom_long), latitude, longitude, note
- BOM UTF-8, séparateur point-virgule

Import (GET/POST lieux/import) :
- Correspondance par nom de colonne (colonne nom obligatoire)
- Résolution du parent par nom_long exact
- Résolution du type par nom exact
- Détection automatique du séparateur ; ou ,
- nom_long recalculé automatiquement par le modèle

Boutons ↓ CSV, ↑ Importer CSV et + Nouveau lieu dans la liste des lieux.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 20:06:46 +02:00
yann64 cdbf6d458c Import CSV des relevés d'une source
- ImportController : create() (formulaire) + store() (traitement)
- Détection automatique du séparateur ; ou ,
- Suppression du BOM UTF-8
- Correspondance colonnes ↔ champs par libellé
- Parsing des types : date (avec calendrier), booléen, nombre, lieu (recherche par nom_long), texte
- Vue sources/import.blade.php : formulaire + liste des colonnes attendues
- Routes sources.import.create / sources.import.store
- Bouton "↑ Importer CSV" dans la fiche source (soumis aux droits create releve)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 20:05:03 +02:00
yann64 f5a7407be0 Connexion par email ou nom d'utilisateur
Le champ de connexion accepte désormais les deux :
- Si la saisie contient un @, Auth::attempt() cherche par email
- Sinon, cherche par name
Champ renommé login dans le formulaire et la LoginRequest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 20:02:50 +02:00
yann64 ff615ca0a1 Suppression de l'export GEDCOM
- Routes export.source et export.recherche supprimées
- ExportController : méthodes source() et recherche() supprimées,
  imports GedcomExportService/DbCompat/Releve/SourceStatus/DB retirés
- Boutons GEDCOM retirés de sources/show, releves/index et recherche/index

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 20:01:33 +02:00
yann64 82e2ae5cd6 Fix z-index nav : menus cachés derrière la carte Leaflet
Leaflet positionne ses couches internes avec z-index jusqu'à 800.
La <nav> sans stacking context propre laissait ses dropdowns (z-50)
passer derrière. Ajout de relative z-40 sur <nav> : crée un stacking
context, les dropdowns (z-50 à l'intérieur) s'affichent par-dessus la carte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:53:55 +02:00
yann64 e29824b575 Carte : remplace Leaflet CDN par bundle Vite local
Le CDN unpkg.com échoue si le serveur n'a pas accès à Internet.
- npm install leaflet
- resources/js/carte.js : point d'entrée qui importe Leaflet + expose window.LeafletMap
- vite.config.js : ajoute carte.js aux inputs compilés
- carte/index.blade.php : @vite au lieu du CDN, utilise window.LeafletMap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:45:12 +02:00
yann64 fdd81977c2 Export CSV des relevés d'une source
- ExportController::sourceCsv() : génère un CSV avec BOM UTF-8 (compatible Excel)
  séparateur point-virgule, en-têtes = labels des champs, valeurs formatées
  (dates avec calendrier si non grégorien, lieux = nom_long, booléens Oui/Non)
- Route GET export/source/{source}/csv → export.source.csv
- Boutons ↓ CSV et ↓ GEDCOM dans la section relevés de la fiche source

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:39:21 +02:00
yann64 79bbf3671a Ajout du type de champ "place" (Lieu) pour les relevés
- FieldType::Place = 'place' dans l'enum
- _field.blade.php : composant lieu-picker réutilisé pour la saisie
- Validation : integer + exists:lieux,id dans Store/UpdateReleveRequest
- buildData() : stocke {id, nom_long} dans le JSONB (évite les requêtes à l'affichage)
- releves/show.blade.php : affiche nom_long pour les champs de type place

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:33:56 +02:00
yann64 d38efaad60 Fix routes shallow releves : Source ne peut pas être bindée sans {source} dans l'URL
Avec ->shallow(), show/edit/update/destroy deviennent /releves/{releve}.
Le contrôleur bindait Source $source depuis la route (introuvable) →
Laravel l'instanciait vide via DI → id null → route() échouait dans la vue.
Correction : charger $source via $releve->source dans les 4 actions shallow.
Redirect update() corrigé : releves.show (shallow) et non sources.releves.show.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:29:49 +02:00
yann64 fe96bbdf6e Fix saisie date relevé : x-show ne retire pas l'input du DOM
Les deux inputs [valeur] (date et texte républicain) avaient le même name.
x-show ne fait que display:none — les deux étaient soumis, PHP prenait le
dernier (républicain vide), ce qui échouait la validation 'required'.
Correction : :disabled="..." sur chaque input pour qu'il ne soit pas soumis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:25:56 +02:00
yann64 5feceb0882 Fix RelevePolicy : harmoniser les signatures avec les appels du Gate
Le Gate Laravel se comporte différemment selon que le 1er argument est
une classe string ou une instance :
- [Releve::class, $source] → Gate passe $source directement (vue + contrôleurs)
- [app(Releve::class), $source] → Gate injecte l'instance + $source (ancien StoreReleveRequest)

Correction : revenir aux signatures originales (User, Source) et remplacer
app(Releve::class) par Releve::class dans StoreReleveRequest pour uniformiser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:22:55 +02:00
yann64 4a5ff3e1a5 Fix RelevePolicy : signature create/viewAny incompatible avec le Gate Laravel
authorize('create', [Releve::class, $source]) injecte une instance Releve
comme 2e argument avant $source — la policy recevait un Releve là où elle
attendait un Source (TypeError). Ajout du paramètre Releve $releve manquant
dans viewAny() et create() ; update() adapté en conséquence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:18:23 +02:00
yann64 156e20c763 Fix modifier champ de type de source : formulaire inline Alpine.js
Le x-data était scopé au bouton seul, rien ne réagissait à open.
- x-data remonté sur le <li> (scope partagé avec le formulaire)
- Formulaire inline avec x-show="open" pour éditer nom/label/type/required/options
- Bouton bascule Modifier ↔ Annuler
- updateField() gère désormais options_raw comme storeField()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:08:14 +02:00
yann64 77dd16143c Fix dashboard MySQL : ONLY_FULL_GROUP_BY sur l'activité mensuelle
DATE_FORMAT(created_at) dans SELECT sans GROUP BY correspondant viole
ONLY_FULL_GROUP_BY (mode par défaut MySQL 5.7+). Correction :
MIN(created_at) comme agrégat + GROUP BY YEAR()/MONTH().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:55:05 +02:00
yann64 bc4dd29ae7 Fix compatibilité MySQL : NULLS LAST et order withCount/select
- DbCompat::nullsLast() : syntaxe portable pgsql/mysql pour ORDER BY NULLS LAST
- RechercheController, ExportController : remplace 'nom ASC NULLS LAST' (pgsql only)
- CarteController : select() avant withCount() pour ne pas effacer le COUNT subquery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:46:31 +02:00
yann64 6271963a94 Fix dashboard admin : requête activité mensuelle compatible MySQL/PostgreSQL
to_char/date_trunc remplacés par DATE_FORMAT pour MySQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 07:47:30 +02:00
yann64 7fcdbdc362 i18n : traductions françaises des vues d'authentification (lang/fr.json)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 07:45:42 +02:00
yann64 d685c2211e CHANGELOG : correction des entrées 1.0.2 et 1.0.3 (correctifs partiels)
Les descriptions initiales présentaient ces versions comme des corrections
définitives alors qu'elles n'adressaient qu'une partie du problème.
Mention explicite du renvoi vers 1.0.4 pour le correctif complet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 07:20:32 +02:00
yann64 a400b7cc66 Release 1.0.4 — changelog + bump version dev 1.0.5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 07:17:51 +02:00
yann64 ed5cfcd275 Fix wizard : suppression totale des sous-processus exec() dans install()
- APP_KEY : généré directement en PHP (random_bytes) + écrit dans .env + propagé
  via config() et putenv() → évite le bug de pattern-matching de key:generate
  (la clé en mémoire ≠ clé dans le .env réécrit par writeEnv)
- DB_* + APP_KEY : putenv() écrase l'env OS hérité au boot (pgsql/temp-key) pour
  que tout sous-processus futur hérite des bonnes valeurs
- optimize supprimé de l'installation : config:cache re-boostrappe l'app via
  bootstrap/app.php dans un contexte où l'Encrypter peut lever MissingAppKeyException ;
  optimize:clear seul suffit — Laravel reconstruit ses caches à la première requête
- key:generate converti en Artisan::call() puis remplacé par génération PHP directe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 07:17:32 +02:00
yann64 4110caa25a Release 1.0.3 — changelog + bump version dev 1.0.4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:03:14 +02:00
yann64 9225abc804 Fix wizard : migrations via Artisan::call() pour éviter l'héritage d'env pgsql
Cause racine : public/index.php charge le .env auto-créé (pgsql) et appelle
putenv('DB_CONNECTION=pgsql'). Les sous-processus exec() héritent cet env OS.
phpdotenv en mode immutable refuse d'écraser une variable déjà définie →
le nouveau .env mysql est ignoré, la migration tente une connexion pgsql.

Fix : reconfiguration de la connexion BDD en mémoire via config() + DB::purge()
puis exécution des migrations via Artisan::call() dans le processus courant.
Plus aucun subprocess pour les migrations → aucun héritage d'env parasite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:19:50 +02:00
yann64 56dbd8a0bf Setup : affichage du numéro de version dans l'assistant d'installation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:12:04 +02:00
yann64 79b831367c Release 1.0.2 — changelog + bump version dev 1.0.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:04:55 +02:00
yann64 715aad58e1 Fix wizard : config:clear avant migrations pour éviter conflit pgsql/mysql
Sans ce clear, un bootstrap/cache/config.php résiduel (produit par
php artisan optimize en dev) est lu en priorité sur le .env réécrit
par writeEnv(), forçant une connexion pgsql même quand mysql est choisi.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:02:45 +02:00
48 changed files with 2011 additions and 298 deletions
+60
View File
@@ -5,6 +5,66 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
--- ---
## [1.0.5] — 2026-06-05
### Ajouté
- **Type de champ « place » (Lieu) dans les relevés** — permet de sélectionner un lieu existant dans la hiérarchie lors de la saisie d'un relevé ; la valeur est stockée en JSONB `{id, nom_long}` et correctement exportée en CSV
- **Export CSV des relevés d'une source** — disponible depuis la fiche source ; colonnes générées dynamiquement à partir des champs du type de source ; valeurs de dates et lieux converties en texte lisible
- **Import CSV des relevés d'une source** — depuis la fiche source ; détection automatique du séparateur (`;` / `,`) ; correspondance des colonnes par libellé (tolérant casse et accents) ; parsing des dates en grégorien ou calendrier nommé, des lieux par nom long
- **Import/export CSV des lieux** — depuis la liste des lieux ; export UTF-8 BOM (compatible Excel) avec colonnes `nom, code, type, lieu_parent, latitude, longitude, note` ; import avec résolution du parent par `nom_long` et du type par nom
- **Connexion avec email ou nom d'utilisateur** — le champ de connexion accepte indifféremment l'adresse e-mail ou le nom d'utilisateur
### Modifié
- **Carte Leaflet — passage au bundle Vite/npm** — la bibliothèque Leaflet est désormais packagée localement (suppression de la dépendance au CDN externe) pour un fonctionnement sur serveurs sans accès internet
- **Suppression de l'export GEDCOM** — la fonctionnalité d'export au format GEDCOM 5.5.1 a été retirée
- **Traductions françaises des vues d'authentification** — formulaires de connexion, inscription et mot de passe oublié entièrement traduits (`lang/fr.json`)
### Corrigé
- **Tableau de bord admin — erreur MySQL `ONLY_FULL_GROUP_BY`** : la requête d'activité mensuelle sélectionnait `DATE_FORMAT(created_at, ...)` sans l'inclure dans le `GROUP BY` ; corrigé en agrégeant avec `MIN(created_at)` et en groupant par `YEAR() / MONTH()`
- **Compatibilité MySQL — `NULLS LAST` et `withCount/select`** : `NULLS LAST` n'est pas supporté en MySQL ; ajout de `DbCompat::nullsLast()` générant la syntaxe portable ; correction de l'ordre des clauses `withCount` / `select` pour MySQL strict
- **Bouton « Modifier » d'un champ de type de source** : le formulaire inline Alpine.js ne s'ouvrait pas car `x-data` était positionné sur le `<button>` et non sur le `<li>` parent partagé avec le formulaire
- **Saisie de date dans un relevé — double soumission** : `x-show` masque visuellement les éléments mais ne les retire pas du DOM ; les deux inputs (grégorien et républicain) étaient soumis simultanément ; l'attribut `:disabled` lié à l'état Alpine résout le problème
- **Affichage / modification / suppression d'un relevé — erreur 500** : les routes `->shallow()` suppriment `{source}` de l'URL pour les actions individuelles sur les relevés ; Laravel ne peut pas binder automatiquement `Source $source` — corrigé en chargeant la source depuis `$releve->source`
- **Menus de navigation masqués derrière la carte Leaflet** : Leaflet utilise des z-index internes jusqu'à 800 ; la balise `<nav>` sans `position: relative` n'avait pas de contexte d'empilement ; ajout de `relative z-40`
- **`RelevePolicy` — signatures incompatibles** : `create` et `viewAny` recevaient `(User, Source)` via le Gate (class string) mais étaient définies sans le paramètre Source, ou avec un paramètre `Releve` inopportun ; signatures harmonisées avec les conventions d'appel du Gate
---
## [1.0.4] — 2026-06-05
### Corrigé
- **Assistant d'installation — APP_KEY vide après installation** : `key:generate` remplace `APP_KEY=<clé_en_mémoire>` par regex dans le `.env`. Mais `writeEnv()` écrit `APP_KEY=` (vide) alors que la clé en mémoire est celle de l'auto-création (clé temporaire) — le pattern ne matche pas, la clé reste vide. Correction : la clé est générée directement en PHP (`random_bytes`), écrite dans le `.env` sans regex, puis propagée en mémoire (`config(['app.key'])`) et dans l'env OS (`putenv`).
- **Assistant d'installation — `MissingAppKeyException` lors de l'optimisation** : `optimize` appelle en interne `config:cache`, qui re-boostrappe une seconde instance de l'application depuis `bootstrap/app.php`. Ce second boot passe par tous les ServiceProviders et peut résoudre l'Encrypter avant que la clé soit accessible, levant `MissingAppKeyException` et renvoyant une erreur 500. Correction : `optimize` est supprimé de la procédure d'installation ; seul `optimize:clear` est conservé pour purger tout cache résiduel. Laravel reconstruit ses caches paresseusement à la première requête.
- **Assistant d'installation — connexion PostgreSQL forcée après login (MySQL)** : `putenv()` n'était appelé que pour les variables `DB_*` mais pas pour `APP_KEY`. Correction : tous les `putenv()` (dont `APP_KEY`) sont regroupés en un bloc cohérent, garantissant que tout sous-processus futur hérite des valeurs correctes.
---
## [1.0.3] — 2026-06-04
### Ajouté
- **Numéro de version dans l'assistant d'installation** — affiché sous le titre « Assistant d'installation » dans le layout du wizard (lu depuis le fichier `VERSION`)
### Corrigé
- **Assistant d'installation — conflit pgsql/mysql (correctif partiel, voir 1.0.4)** : identification de la cause racine (`phpdotenv` immutable + héritage `putenv` par les sous-processus `exec()`). Les migrations sont migrées vers `Artisan::call()` avec reconfiguration en mémoire (`config()` + `DB::purge()`), mais `key:generate` et `optimize` restaient des sous-processus — corrigé complètement en 1.0.4.
- **`.htaccess`** — suppression des directives `php_flag display_errors on` ajoutées temporairement pour déboguer l'erreur 500 sur hébergement mutualisé.
---
## [1.0.2] — 2026-06-04
### Corrigé
- **Assistant d'installation — conflit de connexion pgsql/mysql** *(correctif partiel, voir 1.0.3 et 1.0.4)* : ajout d'un appel à `config:clear` entre l'écriture du `.env` et l'exécution des migrations, et exclusion de `bootstrap/cache/*.php` des archives de déploiement pour ne pas embarquer un cache de configuration de développement.
- **Archives de déploiement** : `bootstrap/cache/*.php` exclu du build rsync.
---
## [1.0.1] — 2026-06-04 ## [1.0.1] — 2026-06-04
### Ajouté ### Ajouté
+1 -1
View File
@@ -1 +1 @@
1.0.2 1.1.0
+1
View File
@@ -10,4 +10,5 @@ enum FieldType: string
case Select = 'select'; case Select = 'select';
case Textarea = 'textarea'; case Textarea = 'textarea';
case Number = 'number'; case Number = 'number';
case Place = 'place';
} }
@@ -50,12 +50,21 @@ class DashboardController extends Controller
->take(10) ->take(10)
->get(); ->get();
// Activité mensuelle des 6 derniers mois // Activité mensuelle des 6 derniers mois (compatible MySQL et PostgreSQL)
$activiteMensuelle = Releve::selectRaw("to_char(date_trunc('month', created_at), 'Mon YYYY') as mois, count(*) as total") $driver = config('database.default');
->where('created_at', '>=', now()->subMonths(5)->startOfMonth()) if ($driver === 'pgsql') {
->groupByRaw("date_trunc('month', created_at)") $activiteMensuelle = Releve::selectRaw("to_char(date_trunc('month', created_at), 'Mon YYYY') as mois, count(*) as total")
->orderByRaw("date_trunc('month', created_at)") ->where('created_at', '>=', now()->subMonths(5)->startOfMonth())
->get(); ->groupByRaw("date_trunc('month', created_at)")
->orderByRaw("date_trunc('month', created_at)")
->get();
} else {
$activiteMensuelle = Releve::selectRaw("DATE_FORMAT(MIN(created_at), '%b %Y') as mois, count(*) as total")
->where('created_at', '>=', now()->subMonths(5)->startOfMonth())
->groupByRaw("YEAR(created_at), MONTH(created_at)")
->orderByRaw("YEAR(created_at), MONTH(created_at)")
->get();
}
return view('admin.dashboard', compact( return view('admin.dashboard', compact(
'sourcesByStatus', 'totalSources', 'totalReleves', 'sourcesByStatus', 'totalSources', 'totalReleves',
@@ -162,13 +162,27 @@ class SettingController extends Controller
public function updateSettings(Request $request): RedirectResponse public function updateSettings(Request $request): RedirectResponse
{ {
$data = $request->validate([ $data = $request->validate([
'site_name' => ['nullable', 'string', 'max:100'], 'site_name' => ['nullable', 'string', 'max:100'],
'search_max_results' => ['nullable', 'integer', 'min:10', 'max:5000'],
]); ]);
$siteName = trim($data['site_name'] ?? ''); $siteName = trim($data['site_name'] ?? '');
SiteSettingsService::set('site_name', $siteName ?: null); SiteSettingsService::set('site_name', $siteName ?: null);
SiteSettingsService::set('registration_enabled', $request->boolean('registration_enabled')); SiteSettingsService::set('registration_enabled', $request->boolean('registration_enabled'));
if (isset($data['search_max_results'])) {
SiteSettingsService::set('search_max_results', (int) $data['search_max_results']);
}
return back()->with('success', 'Paramètres enregistrés.'); return back()->with('success', 'Paramètres enregistrés.');
} }
public function storageLink(): RedirectResponse
{
try {
\Illuminate\Support\Facades\Artisan::call('storage:link');
return back()->with('success', 'Lien de stockage public créé (public/storage → storage/app/public).');
} catch (\Exception $e) {
return back()->with('error', 'Impossible de créer le lien : ' . $e->getMessage());
}
}
} }
@@ -76,7 +76,15 @@ class SourceTypeController extends Controller
public function updateField(StoreSourceTypeFieldRequest $request, SourceType $sourceType, SourceTypeField $field): RedirectResponse public function updateField(StoreSourceTypeFieldRequest $request, SourceType $sourceType, SourceTypeField $field): RedirectResponse
{ {
abort_if($field->source_type_id !== $sourceType->id, 404); abort_if($field->source_type_id !== $sourceType->id, 404);
$field->update($request->validated()); $data = $request->validated();
if ($request->filled('options_raw')) {
$data['options'] = array_filter(array_map('trim', explode("\n", $request->input('options_raw'))));
} elseif ($request->input('type') !== 'select') {
$data['options'] = null;
}
$field->update($data);
return back()->with('success', 'Champ mis à jour.'); return back()->with('success', 'Champ mis à jour.');
} }
+77 -9
View File
@@ -9,6 +9,7 @@ use App\Support\DbCompat;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Enum; use Illuminate\Validation\Rules\Enum;
@@ -48,7 +49,7 @@ class UserController extends Controller
// ── Export CSV ──────────────────────────────────────────────────────────── // ── Export CSV ────────────────────────────────────────────────────────────
public function export(Request $request): Response public function export(Request $request): StreamedResponse
{ {
$query = User::with('sections')->orderBy('name'); $query = User::with('sections')->orderBy('name');
@@ -208,6 +209,33 @@ class UserController extends Controller
return view('admin.utilisateurs.import', compact('results', 'created', 'errors')); return view('admin.utilisateurs.import', compact('results', 'created', 'errors'));
} }
public function create(): View
{
return view('admin.utilisateurs.create');
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'role' => ['required', new Enum(UserRole::class)],
]);
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'role' => $data['role'],
'is_active' => true,
'email_verified_at' => now(),
]);
return redirect()->route('admin.utilisateurs.edit', ['utilisateur' => $user])
->with('success', 'Utilisateur créé.');
}
public function edit(User $user): View public function edit(User $user): View
{ {
$user->load('sections', 'sourcesAssignees'); $user->load('sections', 'sourcesAssignees');
@@ -217,24 +245,64 @@ class UserController extends Controller
public function update(Request $request, User $user): RedirectResponse public function update(Request $request, User $user): RedirectResponse
{ {
$data = $request->validate([ $isSelf = $user->id === auth()->id();
'role' => ['required', new Enum(UserRole::class)],
]);
if ($user->id === auth()->id()) { $rules = [
return back()->with('error', 'Vous ne pouvez pas modifier votre propre rôle.'); 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email,' . $user->id],
'role' => ['required', new Enum(UserRole::class)],
];
if ($request->filled('password')) {
$rules['password'] = ['string', 'min:8', 'confirmed'];
$rules['password_confirmation'] = ['required'];
} }
if ($user->role === UserRole::Admin && $data['role'] !== UserRole::Admin->value) { $data = $request->validate($rules);
// Protection : retrait du dernier admin ou de son propre rôle
if (! $isSelf && $user->role === UserRole::Admin && $data['role'] !== UserRole::Admin->value) {
$adminCount = User::where('role', UserRole::Admin->value)->count(); $adminCount = User::where('role', UserRole::Admin->value)->count();
if ($adminCount <= 1) { if ($adminCount <= 1) {
return back()->with('error', 'Impossible de retirer le rôle admin au dernier administrateur.'); return back()->with('error', 'Impossible de retirer le rôle admin au dernier administrateur.');
} }
} }
$user->update(['role' => $data['role']]); $update = [
'name' => $data['name'],
'email' => $data['email'],
];
return back()->with('success', 'Rôle mis à jour.'); if (! $isSelf) {
$update['role'] = $data['role'];
}
if ($request->filled('password')) {
$update['password'] = Hash::make($data['password']);
}
$user->update($update);
return back()->with('success', 'Utilisateur mis à jour.');
}
public function destroy(User $user): RedirectResponse
{
if ($user->id === auth()->id()) {
return back()->with('error', 'Vous ne pouvez pas supprimer votre propre compte.');
}
if ($user->role === UserRole::Admin) {
$adminCount = User::where('role', UserRole::Admin->value)->count();
if ($adminCount <= 1) {
return back()->with('error', 'Impossible de supprimer le dernier administrateur.');
}
}
$user->delete();
return redirect()->route('admin.utilisateurs.index')
->with('success', 'Utilisateur supprimé.');
} }
public function toggleActive(User $user): RedirectResponse public function toggleActive(User $user): RedirectResponse
+2 -2
View File
@@ -21,8 +21,8 @@ class CarteController extends Controller
->whereHas('sources.releves') ->whereHas('sources.releves')
->with([ ->with([
'sources' => function ($q) { 'sources' => function ($q) {
$q->withCount('releves') $q->select('id', 'nom', 'lieu_id', 'status', 'annee_debut', 'annee_fin')
->select('id', 'nom', 'lieu_id', 'status', 'annee_debut', 'annee_fin') ->withCount('releves')
->orderBy('nom'); ->orderBy('nom');
}, },
]) ])
+46 -86
View File
@@ -2,108 +2,68 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\SourceStatus; use App\Enums\FieldType;
use App\Models\Releve;
use App\Models\Source; use App\Models\Source;
use App\Services\GedcomExportService;
use App\Support\DbCompat;
use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class ExportController extends Controller class ExportController extends Controller
{ {
public function __construct( /** Export CSV de tous les relevés d'une source */
private readonly GedcomExportService $gedcom, public function sourceCsv(Source $source): Response
) {}
/** Export de tous les relevés d'une source */
public function source(Source $source): Response
{ {
$this->authorize('view', $source); $this->authorize('view', $source);
$gedcomContent = $this->gedcom->exportSource($source); $source->load(['sourceType.fields', 'releves']);
$filename = $this->sanitizeFilename($source->nom) . '.ged'; $fields = $source->sourceType->fields->sortBy('order');
return response($gedcomContent, 200, [ $handle = fopen('php://temp', 'r+');
'Content-Type' => 'text/plain; charset=UTF-8',
// BOM UTF-8 pour la compatibilité Excel
fwrite($handle, "\xEF\xBB\xBF");
// En-tête
fputcsv($handle, $fields->pluck('label')->toArray(), ';');
// Lignes
foreach ($source->releves as $releve) {
$row = [];
foreach ($fields as $field) {
$val = $releve->data[$field->name] ?? null;
$row[] = $this->formatCsvValue($val, $field->type);
}
fputcsv($handle, $row, ';');
}
rewind($handle);
$csv = stream_get_contents($handle);
fclose($handle);
$filename = $this->sanitizeFilename($source->nom) . '.csv';
return response($csv, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"", 'Content-Disposition' => "attachment; filename=\"{$filename}\"",
]); ]);
} }
/** Export depuis les résultats de recherche (avec les mêmes filtres) */ private function formatCsvValue(mixed $val, FieldType $type): string
public function recherche(Request $request): Response
{ {
$user = auth()->user(); if ($val === null || $val === '') {
return '';
$query = Releve::with(['source.sourceType', 'createur'])
->whereHas('source', function ($q) use ($user, $request) {
if (! $user->isSectionManager()) {
$assignedIds = $user->sourcesAssignees()->pluck('sources.id');
$q->where(function ($sq) use ($assignedIds) {
$sq->where('status', SourceStatus::Termine)
->orWhereIn('id', $assignedIds);
});
}
if ($request->filled('source_type_id')) {
$q->where('source_type_id', $request->integer('source_type_id'));
}
}); // $request déjà dans le use()
if ($request->filled('q')) {
$q = trim($request->get('q'));
$like = DbCompat::like();
$fts = DbCompat::ftsRaw();
$query->where(function ($wq) use ($q, $like, $fts) {
$wq->where('nom', $like, "%{$q}%")
->orWhere('prenom', $like, "%{$q}%")
->orWhere('date_evenement', $like, "%{$q}%");
if ($fts) {
$wq->orWhereRaw($fts, [$q]);
}
});
} }
if ($request->filled('lieu_id')) { return match ($type) {
$rows = DB::select(" FieldType::Boolean => $val ? 'Oui' : 'Non',
WITH RECURSIVE descendants AS ( FieldType::Date => is_array($val)
SELECT id, nom FROM lieux WHERE id = ? ? trim(($val['valeur'] ?? '') . (
UNION ALL ! empty($val['calendrier']) && $val['calendrier'] !== 'gregorien'
SELECT l.id, l.nom FROM lieux l ? ' (' . $val['calendrier'] . ')'
INNER JOIN descendants d ON l.lieu_parent_id = d.id : ''
) ))
SELECT DISTINCT nom FROM descendants WHERE nom IS NOT NULL : (string) $val,
", [$request->integer('lieu_id')]); FieldType::Place => is_array($val) ? ($val['nom_long'] ?? '') : (string) $val,
default => (string) $val,
$noms = collect($rows)->pluck('nom')->filter(); };
if ($noms->isNotEmpty()) {
$pattern = $noms->map(fn ($n) => preg_quote($n, '/'))->join('|');
$query->whereRaw(DbCompat::jsonRegexRaw('data'), [$pattern]);
}
}
if ($request->filled('annee_debut')) {
$query->whereRaw('date_evenement >= ?', [$request->integer('annee_debut') . '-01-01']);
}
if ($request->filled('annee_fin')) {
$query->whereRaw('date_evenement <= ?', [$request->integer('annee_fin') . '-12-31']);
}
$releves = $query->orderByRaw('nom ASC NULLS LAST')->get();
if ($releves->isEmpty()) {
return back()->with('error', 'Aucun relevé à exporter.');
}
$titre = $request->filled('q') ? 'Recherche_' . $request->get('q') : 'Export';
$gedcomContent = $this->gedcom->exportReleves($releves, $titre);
$filename = $this->sanitizeFilename($titre) . '.ged';
return response($gedcomContent, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
]);
} }
private function sanitizeFilename(string $name): string private function sanitizeFilename(string $name): string
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers;
use App\Enums\FieldType;
use App\Models\Lieu;
use App\Models\Releve;
use App\Models\Source;
use App\Support\DbCompat;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ImportController extends Controller
{
public function create(Source $source): View
{
$this->authorize('create', [Releve::class, $source]);
$source->load('sourceType.fields');
return view('sources.import', compact('source'));
}
public function store(Request $request, Source $source): RedirectResponse
{
$this->authorize('create', [Releve::class, $source]);
$request->validate([
'fichier' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
]);
$source->load('sourceType.fields');
$fieldsByLabel = $source->sourceType->fields->keyBy('label');
$path = $request->file('fichier')->getRealPath();
$handle = fopen($path, 'r');
// Suppression du BOM UTF-8 éventuel
$bom = fread($handle, 3);
if ($bom !== "\xEF\xBB\xBF") {
rewind($handle);
}
// Détection automatique du séparateur (; ou ,)
$firstLine = fgets($handle);
rewind($handle);
if ($bom === "\xEF\xBB\xBF") {
fread($handle, 3);
}
$sep = substr_count($firstLine, ';') >= substr_count($firstLine, ',') ? ';' : ',';
$header = fgetcsv($handle, 0, $sep);
if (! $header) {
fclose($handle);
return back()->with('error', 'Fichier CSV vide ou invalide.');
}
$header = array_map('trim', $header);
$imported = 0;
$userId = auth()->id();
while (($line = fgetcsv($handle, 0, $sep)) !== false) {
if (count(array_filter($line, fn ($v) => $v !== '')) === 0) {
continue;
}
$data = [];
foreach ($header as $i => $label) {
$field = $fieldsByLabel->get($label);
if (! $field) {
continue;
}
$data[$field->name] = $this->parseValue(trim($line[$i] ?? ''), $field->type);
}
$source->releves()->create([
'data' => $data,
'created_by' => $userId,
'updated_by' => $userId,
]);
$imported++;
}
fclose($handle);
if ($imported === 0) {
return back()->with('error', 'Aucun relevé importé — vérifiez que les en-têtes correspondent aux libellés des champs.');
}
return redirect()->route('sources.releves.index', $source)
->with('success', "{$imported} relevé(s) importé(s) avec succès.");
}
private function parseValue(string $raw, FieldType $type): mixed
{
return match ($type) {
FieldType::Boolean => in_array(mb_strtolower($raw), ['oui', 'yes', '1', 'true'], true),
FieldType::Number => $raw !== '' ? (float) str_replace(',', '.', $raw) : null,
FieldType::Date => $this->parseDate($raw),
FieldType::Place => $this->parsePlace($raw),
default => $raw !== '' ? $raw : null,
};
}
private function parseDate(string $raw): array
{
if ($raw === '') {
return ['valeur' => null, 'calendrier' => 'gregorien'];
}
// Format export : "YYYY-MM-DD" ou "YYYY-MM-DD (calendrier)"
if (preg_match('/^(.+?)\s*\((\w+)\)\s*$/', $raw, $m)) {
return ['valeur' => trim($m[1]), 'calendrier' => trim($m[2])];
}
return ['valeur' => $raw, 'calendrier' => 'gregorien'];
}
private function parsePlace(string $raw): ?array
{
if ($raw === '') {
return null;
}
$like = DbCompat::like();
$lieu = Lieu::where('nom_long', $raw)->first(['id', 'nom_long'])
?? Lieu::where('nom_long', $like, $raw . '%')->first(['id', 'nom_long']);
return $lieu
? ['id' => $lieu->id, 'nom_long' => $lieu->nom_long]
: ['id' => null, 'nom_long' => $raw];
}
}
+110 -2
View File
@@ -10,6 +10,7 @@ use App\Support\DbCompat;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\View\View; use Illuminate\View\View;
@@ -65,9 +66,10 @@ class LieuController extends Controller
$lieuSelectionne = $request->filled('lieu_id') $lieuSelectionne = $request->filled('lieu_id')
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long']) ? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
: null; : null;
$lieux = $query->paginate(50)->withQueryString(); $hasFilters = $request->anyFilled(['lieu_type_id', 'q', 'lieu_id']);
$lieux = $hasFilters ? $query->paginate(50)->withQueryString() : null;
return view('lieux.index', compact('lieux', 'lieuTypes', 'lieuSelectionne')); return view('lieux.index', compact('lieux', 'lieuTypes', 'lieuSelectionne', 'hasFilters'));
} }
public function create(): View public function create(): View
@@ -129,6 +131,112 @@ class LieuController extends Controller
->with('success', 'Lieu supprimé.'); ->with('success', 'Lieu supprimé.');
} }
public function exportCsv(): Response
{
$this->authorize('viewAny', Lieu::class);
$lieux = Lieu::with(['lieuType', 'parent'])->orderBy('nom_long')->get();
$handle = fopen('php://temp', 'r+');
fwrite($handle, "\xEF\xBB\xBF");
fputcsv($handle, ['nom', 'code', 'type', 'lieu_parent', 'latitude', 'longitude', 'note'], ';');
foreach ($lieux as $lieu) {
fputcsv($handle, [
$lieu->nom,
$lieu->code ?? '',
$lieu->lieuType?->nom ?? '',
$lieu->parent?->nom_long ?? '',
$lieu->latitude ?? '',
$lieu->longitude ?? '',
$lieu->note ?? '',
], ';');
}
rewind($handle);
$csv = stream_get_contents($handle);
fclose($handle);
return response($csv, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="lieux.csv"',
]);
}
public function importCreate(): View
{
$this->authorize('create', Lieu::class);
return view('lieux.import');
}
public function importStore(Request $request): RedirectResponse
{
$this->authorize('create', Lieu::class);
$request->validate([
'fichier' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
]);
$handle = fopen($request->file('fichier')->getRealPath(), 'r');
$bom = fread($handle, 3);
if ($bom !== "\xEF\xBB\xBF") {
rewind($handle);
}
$firstLine = fgets($handle);
rewind($handle);
if ($bom === "\xEF\xBB\xBF") {
fread($handle, 3);
}
$sep = substr_count($firstLine, ';') >= substr_count($firstLine, ',') ? ';' : ',';
$header = array_map('trim', fgetcsv($handle, 0, $sep) ?: []);
$colIdx = array_flip($header);
$lieuTypesCache = LieuType::all()->keyBy('nom');
$imported = 0;
while (($line = fgetcsv($handle, 0, $sep)) !== false) {
$nom = trim($line[$colIdx['nom'] ?? -1] ?? '');
if ($nom === '') {
continue;
}
$parentNomLong = trim($line[$colIdx['lieu_parent'] ?? -1] ?? '');
$typeName = trim($line[$colIdx['type'] ?? -1] ?? '');
$parentId = $parentNomLong
? Lieu::where('nom_long', $parentNomLong)->value('id')
: null;
$lieuTypeId = $typeName
? $lieuTypesCache->get($typeName)?->id
: null;
Lieu::create([
'nom' => $nom,
'code' => trim($line[$colIdx['code'] ?? -1] ?? '') ?: null,
'lieu_type_id' => $lieuTypeId,
'lieu_parent_id'=> $parentId,
'latitude' => ($v = trim($line[$colIdx['latitude'] ?? -1] ?? '')) !== '' ? (float) str_replace(',', '.', $v) : null,
'longitude' => ($v = trim($line[$colIdx['longitude'] ?? -1] ?? '')) !== '' ? (float) str_replace(',', '.', $v) : null,
'note' => trim($line[$colIdx['note'] ?? -1] ?? '') ?: null,
]);
$imported++;
}
fclose($handle);
if ($imported === 0) {
return back()->with('error', 'Aucun lieu importé — vérifiez que la colonne « nom » est présente.');
}
return redirect()->route('lieux.index')
->with('success', "{$imported} lieu(x) importé(s) avec succès.");
}
private function getDescendantAndSelfIds(int $lieuId): array private function getDescendantAndSelfIds(int $lieuId): array
{ {
$rows = DB::select(" $rows = DB::select("
+14 -10
View File
@@ -6,6 +6,7 @@ use App\Enums\SourceStatus;
use App\Models\Lieu; use App\Models\Lieu;
use App\Models\Releve; use App\Models\Releve;
use App\Models\SourceType; use App\Models\SourceType;
use App\Services\SiteSettingsService;
use App\Support\DbCompat; use App\Support\DbCompat;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -18,6 +19,7 @@ class RechercheController extends Controller
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']); $sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
$resultats = null; $resultats = null;
$total = null; $total = null;
$limited = false;
// Charger le lieu sélectionné pour pré-remplir le picker // Charger le lieu sélectionné pour pré-remplir le picker
$lieuSelectionne = $request->filled('lieu_id') $lieuSelectionne = $request->filled('lieu_id')
@@ -25,10 +27,10 @@ class RechercheController extends Controller
: null; : null;
if ($request->anyFilled(['q', 'source_type_id', 'lieu_id', 'annee_debut', 'annee_fin'])) { if ($request->anyFilled(['q', 'source_type_id', 'lieu_id', 'annee_debut', 'annee_fin'])) {
[$resultats, $total] = $this->search($request); [$resultats, $total, $limited] = $this->search($request);
} }
return view('recherche.index', compact('sourceTypes', 'resultats', 'total', 'lieuSelectionne')); return view('recherche.index', compact('sourceTypes', 'resultats', 'total', 'limited', 'lieuSelectionne'));
} }
private function search(Request $request): array private function search(Request $request): array
@@ -85,15 +87,17 @@ class RechercheController extends Controller
$query->whereRaw("date_evenement <= ?", [$request->integer('annee_fin') . '-12-31']); $query->whereRaw("date_evenement <= ?", [$request->integer('annee_fin') . '-12-31']);
} }
// ── Tri + pagination ──────────────────────────────────────────────── // ── Limite configurable par l'admin ─────────────────────────────────
$total = $query->count(); $max = SiteSettingsService::searchMaxResults();
$resultats = $query $total = $query->count();
->orderByRaw('nom ASC NULLS LAST')
->orderByRaw('date_evenement ASC NULLS LAST')
->paginate(25)
->withQueryString();
return [$resultats, $total]; $resultats = $query
->orderByRaw(DbCompat::nullsLast('nom'))
->orderByRaw(DbCompat::nullsLast('date_evenement'))
->limit($max)
->get();
return [$resultats, $total, $total > $max];
} }
/** /**
+14 -7
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Enums\FieldType; use App\Enums\FieldType;
use App\Http\Requests\StoreReleveRequest; use App\Http\Requests\StoreReleveRequest;
use App\Http\Requests\UpdateReleveRequest; use App\Http\Requests\UpdateReleveRequest;
use App\Models\Lieu;
use App\Models\Releve; use App\Models\Releve;
use App\Models\Source; use App\Models\Source;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -49,27 +50,28 @@ class ReleveController extends Controller
->with('success', 'Relevé ajouté.'); ->with('success', 'Relevé ajouté.');
} }
public function show(Source $source, Releve $releve): View public function show(Releve $releve): View
{ {
$this->authorize('view', $releve); $this->authorize('view', $releve);
$source->load('sourceType.fields'); $source = $releve->source->load('sourceType.fields');
$releve->load('createur', 'modificateur'); $releve->load('createur', 'modificateur');
return view('releves.show', compact('source', 'releve')); return view('releves.show', compact('source', 'releve'));
} }
public function edit(Source $source, Releve $releve): View public function edit(Releve $releve): View
{ {
$this->authorize('update', $releve); $this->authorize('update', $releve);
$source->load('sourceType.fields'); $source = $releve->source->load('sourceType.fields');
return view('releves.edit', compact('source', 'releve')); return view('releves.edit', compact('source', 'releve'));
} }
public function update(UpdateReleveRequest $request, Source $source, Releve $releve): RedirectResponse public function update(UpdateReleveRequest $request, Releve $releve): RedirectResponse
{ {
$source = $releve->source->load('sourceType.fields');
$data = $this->buildData($request->validated()['data'] ?? [], $source); $data = $this->buildData($request->validated()['data'] ?? [], $source);
$releve->update([ $releve->update([
@@ -77,14 +79,15 @@ class ReleveController extends Controller
'updated_by' => $request->user()->id, 'updated_by' => $request->user()->id,
]); ]);
return redirect()->route('sources.releves.show', [$source, $releve]) return redirect()->route('releves.show', $releve)
->with('success', 'Relevé mis à jour.'); ->with('success', 'Relevé mis à jour.');
} }
public function destroy(Source $source, Releve $releve): RedirectResponse public function destroy(Releve $releve): RedirectResponse
{ {
$this->authorize('delete', $releve); $this->authorize('delete', $releve);
$source = $releve->source;
$releve->delete(); $releve->delete();
return redirect()->route('sources.releves.index', $source) return redirect()->route('sources.releves.index', $source)
@@ -106,6 +109,10 @@ class ReleveController extends Controller
'valeur' => $value['valeur'] ?? null, 'valeur' => $value['valeur'] ?? null,
'calendrier' => $value['calendrier'] ?? 'gregorien', 'calendrier' => $value['calendrier'] ?? 'gregorien',
], ],
FieldType::Place => $value ? (function () use ($value) {
$lieu = Lieu::find((int) $value, ['id', 'nom_long']);
return $lieu ? ['id' => $lieu->id, 'nom_long' => $lieu->nom_long] : null;
})() : null,
default => $value === '' ? null : $value, default => $value === '' ? null : $value,
}; };
} }
+84 -13
View File
@@ -3,6 +3,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use PDO; use PDO;
use PDOException; use PDOException;
@@ -126,8 +128,6 @@ class SetupController extends Controller
$steps = []; $steps = [];
$success = true; $success = true;
$php = $this->phpBinary();
$artisan = $php . ' ' . escapeshellarg(base_path('artisan'));
// 1. Écriture du .env // 1. Écriture du .env
try { try {
@@ -138,18 +138,74 @@ class SetupController extends Controller
$success = false; $success = false;
} }
// 2. Génération de la clé APP_KEY // 2. Génération de la clé APP_KEY — directement en PHP, sans passer par key:generate.
//
// Artisan key:generate remplace APP_KEY=<clé_en_mémoire> dans le .env grâce à un
// pattern regex. Mais writeEnv() vient d'écrire APP_KEY= (vide) alors qu'en mémoire
// la clé est celle de l'auto-création (TEMP_KEY) → le pattern ne matche pas → la
// clé reste vide dans le .env et la config:cache en hérite.
// Solution : générer la clé nous-mêmes, l'écrire directement dans le .env, et la
// propager en mémoire + env OS dès maintenant.
$appKey = null;
if ($success) { if ($success) {
[$ok, $out] = $this->artisanRun($artisan, 'key:generate --force'); try {
$steps[] = ['ok' => $ok, 'label' => 'Génération de la clé de chiffrement (APP_KEY)', 'error' => $ok ? null : $out]; $appKey = 'base64:' . base64_encode(random_bytes(32));
if (! $ok) $success = false; $envPath = base_path('.env');
$env = file_get_contents($envPath);
$env = preg_replace('/^APP_KEY=.*/m', 'APP_KEY=' . $appKey, $env);
file_put_contents($envPath, $env);
config(['app.key' => $appKey]);
$steps[] = ['ok' => true, 'label' => 'Génération de la clé de chiffrement (APP_KEY)'];
} catch (\Exception $e) {
$steps[] = ['ok' => false, 'label' => 'Génération de la clé de chiffrement (APP_KEY)', 'error' => $e->getMessage()];
$success = false;
}
} }
// 3. Migrations // 2b. Reconfiguration de la connexion BDD — processus courant ET sous-processus.
//
// putenv() écrase l'env OS hérité au boot (pgsql + TEMP_KEY) pour que tous les
// sous-processus futurs (config:cache interne à optimize…) reçoivent les bonnes
// valeurs. config() + DB::purge() reconfigure le processus courant en mémoire.
if ($success) { if ($success) {
[$ok, $out] = $this->artisanRun($artisan, 'migrate --force'); putenv("APP_KEY={$appKey}");
$steps[] = ['ok' => $ok, 'label' => 'Migration de la base de données', 'error' => $ok ? null : $out]; putenv("DB_CONNECTION={$dbData['driver']}");
if (! $ok) $success = false; putenv("DB_HOST={$dbData['host']}");
putenv("DB_PORT={$dbData['port']}");
putenv("DB_DATABASE={$dbData['database']}");
putenv("DB_USERNAME={$dbData['username']}");
putenv('DB_PASSWORD=' . ($dbData['password'] ?? ''));
$connConfig = $dbData['driver'] === 'pgsql'
? ['driver' => 'pgsql', 'host' => $dbData['host'], 'port' => (int) $dbData['port'],
'database' => $dbData['database'], 'username' => $dbData['username'],
'password' => $dbData['password'] ?? '', 'charset' => 'utf8', 'prefix' => '',
'schema' => 'public', 'sslmode' => 'prefer']
: ['driver' => 'mysql', 'host' => $dbData['host'], 'port' => (int) $dbData['port'],
'database' => $dbData['database'], 'username' => $dbData['username'],
'password' => $dbData['password'] ?? '', 'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'strict' => true];
config([
'database.default' => $dbData['driver'],
"database.connections.{$dbData['driver']}" => $connConfig,
]);
DB::purge($dbData['driver']);
}
// 3. Migrations (dans le processus courant, config BDD déjà écrasée ci-dessus)
if ($success) {
try {
$exitCode = Artisan::call('migrate', ['--force' => true]);
$out = trim(Artisan::output());
$ok = $exitCode === 0;
$steps[] = ['ok' => $ok, 'label' => 'Migration de la base de données', 'error' => $ok ? null : $out];
if (! $ok) $success = false;
} catch (\Exception $e) {
$steps[] = ['ok' => false, 'label' => 'Migration de la base de données', 'error' => $e->getMessage()];
$success = false;
}
} }
// 4. Création du compte administrateur // 4. Création du compte administrateur
@@ -163,6 +219,17 @@ class SetupController extends Controller
} }
} }
// 4b. Lien de stockage public (symlink public/storage → storage/app/public)
// Non bloquant : l'installation continue même si le serveur interdit les symlinks.
if ($success) {
try {
\Illuminate\Support\Facades\Artisan::call('storage:link');
$steps[] = ['ok' => true, 'label' => 'Lien de stockage public créé'];
} catch (\Exception $e) {
$steps[] = ['ok' => false, 'label' => 'Lien de stockage public (non bloquant — créez-le manuellement via « Administration → Paramètres »)', 'error' => $e->getMessage()];
}
}
// 5. Paramètres du site // 5. Paramètres du site
if ($success) { if ($success) {
try { try {
@@ -176,10 +243,14 @@ class SetupController extends Controller
} }
} }
// 6. Optimisation des caches // 6. Nettoyage des caches
// optimize:clear supprime tout cache résiduel (config, routes, vues, events).
// On n'appelle PAS optimize : config:cache re-boostrappe l'app depuis bootstrap/app.php
// dans un contexte qui peut ne pas avoir accès à notre APP_KEY via putenv, ce qui
// provoque MissingAppKeyException. Laravel reconstruit ses caches à la première
// requête — pas besoin de les préchauffer pendant l'installation.
if ($success) { if ($success) {
$this->artisanRun($artisan, 'optimize:clear'); Artisan::call('optimize:clear');
$this->artisanRun($artisan, 'optimize');
} }
// 7. Marquage installation // 7. Marquage installation
+9 -2
View File
@@ -69,9 +69,16 @@ class SourceController extends Controller
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long']) ? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
: null; : null;
$sources = $query->orderBy('nom')->paginate(25)->withQueryString(); // Pour les admins/responsables, exiger au moins un filtre avant d'afficher
// les résultats (ils peuvent voir potentiellement des milliers de sources).
// Les membres normaux voient toujours leurs sources (déjà filtrées par accès).
$hasFilters = $request->anyFilled(['status', 'source_type_id', 'lieu_id', 'annee_debut', 'annee_fin']);
$requiresFilter = $user->isSectionManager();
$sources = ($requiresFilter && ! $hasFilters)
? null
: $query->orderBy('nom')->paginate(25)->withQueryString();
return view('sources.index', compact('sources', 'sourceTypes', 'lieuSelectionne')); return view('sources.index', compact('sources', 'sourceTypes', 'lieuSelectionne', 'hasFilters'));
} }
private function getLieuDescendantIds(int $lieuId): array private function getLieuDescendantIds(int $lieuId): array
+9 -19
View File
@@ -28,25 +28,23 @@ class LoginRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'email' => ['required', 'string', 'email'], 'login' => ['required', 'string'],
'password' => ['required', 'string'], 'password' => ['required', 'string'],
]; ];
} }
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void public function authenticate(): void
{ {
$this->ensureIsNotRateLimited(); $this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { $login = $this->string('login')->toString();
$field = filter_var($login, FILTER_VALIDATE_EMAIL) ? 'email' : 'name';
if (! Auth::attempt([$field => $login, 'password' => $this->string('password')->toString()], $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey()); RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => trans('auth.failed'), 'login' => trans('auth.failed'),
]); ]);
} }
@@ -55,18 +53,13 @@ class LoginRequest extends FormRequest
RateLimiter::hit($this->throttleKey()); RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => 'Votre compte est désactivé. Contactez un administrateur.', 'login' => 'Votre compte est désactivé. Contactez un administrateur.',
]); ]);
} }
RateLimiter::clear($this->throttleKey()); RateLimiter::clear($this->throttleKey());
} }
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void public function ensureIsNotRateLimited(): void
{ {
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
@@ -78,18 +71,15 @@ class LoginRequest extends FormRequest
$seconds = RateLimiter::availableIn($this->throttleKey()); $seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => trans('auth.throttle', [ 'login' => trans('auth.throttle', [
'seconds' => $seconds, 'seconds' => $seconds,
'minutes' => ceil($seconds / 60), 'minutes' => ceil($seconds / 60),
]), ]),
]); ]);
} }
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string public function throttleKey(): string
{ {
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); return Str::transliterate(Str::lower($this->string('login')).'|'.$this->ip());
} }
} }
+5 -1
View File
@@ -13,7 +13,7 @@ class StoreReleveRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
$source = $this->route('source'); $source = $this->route('source');
return $this->user()->can('create', [app(\App\Models\Releve::class), $source]); return $this->user()->can('create', [\App\Models\Releve::class, $source]);
} }
public function rules(): array public function rules(): array
@@ -46,6 +46,10 @@ class StoreReleveRequest extends FormRequest
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)]; $rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)];
break; break;
case FieldType::Place:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'integer', 'exists:lieux,id'];
break;
default: // text, textarea default: // text, textarea
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000']; $rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
} }
@@ -41,6 +41,9 @@ class UpdateReleveRequest extends FormRequest
$options = $field->options ?? []; $options = $field->options ?? [];
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)]; $rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)];
break; break;
case FieldType::Place:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'integer', 'exists:lieux,id'];
break;
default: default:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000']; $rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
} }
+7
View File
@@ -88,6 +88,13 @@ class SiteSettingsService
return (bool) self::get('registration_enabled', false); return (bool) self::get('registration_enabled', false);
} }
// ── Recherche ────────────────────────────────────────────────────────────────
public static function searchMaxResults(): int
{
return max(10, (int) self::get('search_max_results', 200));
}
// ── Mises à jour ────────────────────────────────────────────────────────── // ── Mises à jour ──────────────────────────────────────────────────────────
public static function updatesDisabled(): bool public static function updatesDisabled(): bool
+12
View File
@@ -58,6 +58,18 @@ class DbCompat
: "JSON_UNQUOTE(JSON_EXTRACT(data, '$.$jsonKey'))"; : "JSON_UNQUOTE(JSON_EXTRACT(data, '$.$jsonKey'))";
} }
/**
* Fragment ORDER BY "colonne ASC, nulls en dernier".
* Usage : ->orderByRaw(DbCompat::nullsLast('nom'))
*/
public static function nullsLast(string $column, string $direction = 'ASC'): string
{
$dir = strtoupper($direction);
return self::isPgsql()
? "{$column} {$dir} NULLS LAST"
: "({$column} IS NULL) ASC, {$column} {$dir}";
}
/** Syntaxe de la colonne générée stockée pour un champ JSON imbriqué (ex: date_evenement.valeur) */ /** Syntaxe de la colonne générée stockée pour un champ JSON imbriqué (ex: date_evenement.valeur) */
public static function generatedJsonNestedCol(string $jsonPath): string public static function generatedJsonNestedCol(string $jsonPath): string
{ {
+691
View File
@@ -0,0 +1,691 @@
# Documentation — MesRelevés
## Table des matières
1. [Présentation](#1-présentation)
2. [Stack technique](#2-stack-technique)
3. [Installation](#3-installation)
4. [Schéma de base de données](#4-schéma-de-base-de-données)
5. [Modèles Eloquent](#5-modèles-eloquent)
6. [Authentification et autorisations](#6-authentification-et-autorisations)
7. [Fonctionnalités](#7-fonctionnalités)
8. [Routes](#8-routes)
9. [Services](#9-services)
10. [Énumérations](#10-énumérations)
11. [Middlewares](#11-middlewares)
12. [Administration](#12-administration)
13. [Mises à jour](#13-mises-à-jour)
14. [Commandes Artisan](#14-commandes-artisan)
15. [Performance et indexation](#15-performance-et-indexation)
---
## 1. Présentation
**MesRelevés** est une application web dédiée aux associations de généalogie. Elle permet :
- la **saisie systématique** de relevés d'actes d'état civil (naissance, mariage, décès, etc.) ;
- la **recherche** plein texte dans ces relevés, avec filtres par type, lieu et période ;
- l'**export au format GEDCOM 5.5.1** pour import dans les logiciels de généalogie ;
- la **gestion collaborative** par sections locales, avec un workflow de validation par statut.
L'application supporte deux bases de données : **PostgreSQL 16** (recommandé, avec JSONB et recherche plein texte native) et MySQL/MariaDB.
---
## 2. Stack technique
| Composant | Technologie |
|-----------|-------------|
| Langage | PHP 8.2+ |
| Framework | Laravel 12 |
| Base de données | PostgreSQL 16 (recommandé) ou MySQL 8+ |
| Frontend | Blade + Alpine.js + Tailwind CSS |
| Assets | Vite |
| Cartographie | Leaflet.js + OpenStreetMap |
| Authentification | Laravel Breeze (sessions) + 2FA par code PIN |
| Cache | Redis (via Laravel Cache) |
---
## 3. Installation
### Prérequis
- PHP 8.2+ avec extensions : `pdo_pgsql` (ou `pdo_mysql`), `mbstring`, `json`, `xml`, `curl`
- Composer
- Node.js 18+ et npm
- PostgreSQL 16+ ou MySQL 8+
- Redis (optionnel, pour le cache)
### Installation manuelle
```bash
# Cloner le dépôt
git clone <url-du-depot> mesreleves
cd mesreleves
# Installer les dépendances
composer install --no-dev
npm install && npm run build
# Configurer l'environnement
cp .env.example .env
php artisan key:generate
# Éditer .env : APP_URL, DB_*, MAIL_*, CACHE_DRIVER
# Puis lancer les migrations
php artisan migrate
# Créer le lien de stockage public
php artisan storage:link
```
### Assistant d'installation (wizard)
L'application inclut un assistant graphique accessible à l'URL `/setup` lors du premier lancement (avant la création du fichier `storage/installed`). Il guide l'administrateur à travers :
1. **Connexion à la base de données** — saisie des paramètres et test de connexion
2. **Paramètres de l'application** — nom du site, URL, fuseau horaire
3. **Compte administrateur** — création du premier utilisateur
L'assistant exécute ensuite les migrations et crée le fichier `storage/installed`.
### Environnement de développement
```bash
php artisan serve # http://localhost:8000
npm run dev # Vite en watch (CSS/JS)
php artisan migrate:fresh --seed # Reset + données de test
```
---
## 4. Schéma de base de données
### Vue d'ensemble
```
lieu_types ──< lieux >── (auto-référentiel : lieu_parent_id)
├──< sections >──< section_user >── users
└──< sources >──< source_user >── users
├── source_types ──< source_type_fields
├── depots
└──< releves
```
### Tables
#### `users`
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | bigint PK | |
| `name` | string | Nom de l'utilisateur |
| `email` | string unique | Adresse e-mail (identifiant de connexion) |
| `password` | string | Hash bcrypt |
| `role` | string | `admin` \| `section_manager` \| `member` |
| `is_active` | boolean | Compte actif (défaut : true) |
| `email_verified_at` | timestamp | Date de vérification |
| `remember_token` | string | |
| `created_at`, `updated_at` | timestamp | |
#### `lieu_types`
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | bigint PK | |
| `nom` | string | Ex : « Commune », « Département », « Pays » |
#### `lieux`
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | bigint PK | |
| `lieu_type_id` | bigint FK nullable | → `lieu_types` |
| `nom` | string | Nom court |
| `code` | string nullable | Code INSEE, postal, etc. |
| `lieu_parent_id` | bigint FK nullable | Auto-référentiel (hiérarchie) |
| `nom_long` | string nullable | Calculé automatiquement (ex : « Bordeaux, Gironde, France ») |
| `latitude` | decimal(10,7) nullable | Coordonnées GPS |
| `longitude` | decimal(10,7) nullable | |
| `note` | text nullable | |
#### `sections`
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | bigint PK | |
| `nom` | string | |
| `lieu_id` | bigint FK nullable | → `lieux` |
| `adresse` | string nullable | |
| `email_contact` | string nullable | |
| `url` | string nullable | |
#### `section_user` (pivot)
| Colonne | Type | Description |
|---------|------|-------------|
| `section_id` | bigint FK | → `sections` (cascade delete) |
| `user_id` | bigint FK | → `users` (cascade delete) |
| `role_in_section` | string | `section_manager` \| `member` |
#### `depots`
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | bigint PK | |
| `nom` | string | |
| `description` | text nullable | |
| `adresse_postale` | string nullable | |
| `url` | string nullable | |
#### `source_types`
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | bigint PK | |
| `nom` | string | Ex : « Acte de naissance », « Registre paroissial » |
| `description` | text nullable | |
#### `source_type_fields`
Définit les champs dynamiques du formulaire de saisie pour chaque type de source.
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | bigint PK | |
| `source_type_id` | bigint FK | → `source_types` (cascade delete) |
| `name` | string | Clé JSON (ex : `nom_pere`) — unique par source_type |
| `label` | string | Libellé affiché dans le formulaire |
| `type` | string | `FieldType` : `text`, `date`, `boolean`, `select`, `textarea`, `number`, `place` |
| `required` | boolean | |
| `order` | smallint | Ordre d'affichage |
| `options` | json nullable | Pour `type=select` : liste des choix possibles |
#### `sources`
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | bigint PK | |
| `nom` | string | |
| `description` | text nullable | |
| `source_type_id` | bigint FK | → `source_types` |
| `depot_id` | bigint FK nullable | → `depots` (null si dépôt supprimé) |
| `section_id` | bigint FK nullable | → `sections` |
| `lieu_id` | bigint FK nullable | → `lieux` |
| `annee_debut` | smallint nullable | Période couverte |
| `annee_fin` | smallint nullable | |
| `cote` | string nullable | Cote d'archives |
| `auteur` | string nullable | |
| `status` | string | `SourceStatus` : `a_faire``en_cours``a_valider``termine` |
#### `source_user` (pivot)
| Colonne | Type | Description |
|---------|------|-------------|
| `source_id` | bigint FK | → `sources` (cascade delete) |
| `user_id` | bigint FK | → `users` (cascade delete) |
#### `releves`
| Colonne | Type | Description |
|---------|------|-------------|
| `id` | bigint PK | |
| `source_id` | bigint FK | → `sources` (cascade delete) |
| `data` | jsonb (pgsql) / json (mysql) | Champs variables selon le type de source |
| `created_by` | bigint FK | → `users` |
| `updated_by` | bigint FK | → `users` |
| `created_at`, `updated_at` | timestamp | |
| `nom` | text GENERATED | Extrait de `data->>'nom'` |
| `prenom` | text GENERATED | Extrait de `data->>'prenom'` |
| `date_evenement` | text GENERATED | Extrait de `data->'date_evenement'->>'valeur'` |
Les colonnes générées sont indexées (B-tree). Sur PostgreSQL, un index GIN couvre la colonne `data` entière.
#### `notifications`
Table standard Laravel pour les notifications en base de données (UUID comme clé primaire).
---
## 5. Modèles Eloquent
### User
**Relations :** `sections` (BelongsToMany via `section_user`), `sourcesAssignees` (BelongsToMany via `source_user`)
**Méthodes :**
| Méthode | Description |
|---------|-------------|
| `isAdmin()` | `true` si rôle `admin` |
| `isSectionManager()` | `true` si rôle `admin` ou `section_manager` |
| `isMemberOfSection(Section)` | Appartient à la section |
| `isManagerOfSection(Section)` | Est responsable de la section (ou admin) |
### Lieu
**Relations :** `lieuType`, `parent`, `enfants`, `sections`, `sources`, `releves` (HasManyThrough)
Le `nom_long` est recalculé automatiquement à la création et à la mise à jour (remonte la hiérarchie parente).
### Section
**Relations :** `lieu`, `membres` (BelongsToMany), `sources`, `responsables` (BelongsToMany filtré sur `role_in_section = section_manager`)
### Source
**Relations :** `sourceType`, `depot`, `section`, `lieu`, `membres` (BelongsToMany), `releves`
**Méthodes :**
| Méthode | Description |
|---------|-------------|
| `isVisibleBy(User)` | La source est visible par cet utilisateur |
| `canTransitionTo(SourceStatus, User)` | La transition de statut est autorisée |
### Releve
**Relations :** `source`, `createur` (User via `created_by`), `modificateur` (User via `updated_by`)
Le champ `data` est casté en tableau PHP (`'data' => 'array'`).
### SourceType / SourceTypeField
**Relations :** `SourceType``fields` (HasMany), `SourceTypeField``sourceType` (BelongsTo)
---
## 6. Authentification et autorisations
### Authentification
L'authentification repose sur Laravel Breeze (sessions). Le flux standard est :
1. Saisie de l'e-mail et du mot de passe
2. Si les credentials sont valides, un **code PIN à 6 chiffres** est envoyé par e-mail (2FA)
3. Saisie du code PIN (validité : 10 minutes, renvoi possible)
4. Accès à l'application
Le code PIN est stocké hashé en session (`2fa.pin_hash`) avec sa date d'expiration.
### Roles
| Valeur | Label | Description |
|--------|-------|-------------|
| `admin` | Administrateur | Accès complet à toute l'application |
| `section_manager` | Responsable de section | Gestion de sa section, validation des relevés |
| `member` | Membre | Saisie de relevés sur les sources assignées |
Un administrateur a **tous les droits** d'un responsable de section, et un responsable de section a tous les droits d'un membre.
### Tableau des autorisations
| Action | Admin | Resp. Section | Membre assigné | Membre |
|--------|:-----:|:-------------:|:--------------:|:------:|
| Voir liste des sources | ✓ | ✓ | ✓ | ✓ |
| Créer une source | ✓ | ✓ | | |
| Modifier une source | ✓ | ✓ | | |
| Supprimer une source | ✓ | | | |
| Assigner un membre | ✓ | ✓ | | |
| Voir relevés (source `terminée`) | ✓ | ✓ | ✓ | ✓ |
| Voir relevés (autres statuts) | ✓ | ✓ | ✓ | |
| Saisir / modifier un relevé | ✓ | ✓ | ✓ | |
| Supprimer un relevé | ✓ | ✓ | | |
| Passer status → `a_valider` | ✓ | ✓ | ✓ | |
| Passer status → `termine` | ✓ | ✓ | | |
| Gérer utilisateurs / sections | ✓ | | | |
### Workflow de statut d'une source
```
a_faire ──► en_cours ──► a_valider ──► termine
◄──────────── (rejet → retour en_cours)
```
Lors du passage en `a_valider`, une notification (e-mail + notification interne) est envoyée aux administrateurs et aux responsables de la section concernée.
### Policies
| Policy | Modèle | Actions couvertes |
|--------|--------|-------------------|
| `SourcePolicy` | `Source` | `viewAny`, `view`, `create`, `update`, `delete`, `assignMembre`, `transition` |
| `RelevePolicy` | `Releve` | `viewAny`, `view`, `create`, `update`, `delete` |
| `LieuPolicy` | `Lieu` | `create`, `update`, `delete` |
---
## 7. Fonctionnalités
### Gestion des lieux
- Hiérarchie libre de lieux (commune → département → pays, ou toute autre arborescence)
- Types de lieux configurables (admin)
- Calcul automatique du `nom_long` à partir de la hiérarchie parente
- Coordonnées GPS (latitude/longitude) pour la vue cartographique
- Import et export CSV
- Recherche AJAX par nom (endpoint `/lieux/search`)
### Gestion des sources
- Une source = un registre ou document à dépouiller
- Associée à un type de source, un dépôt d'archives, une section, un lieu et une période
- Membres assignés pour la saisie (table pivot `source_user`)
- Workflow de statut avec notifications automatiques
### Saisie des relevés
Le formulaire de saisie est **dynamique** : il est piloté par les champs définis dans `source_type_fields`. Chaque champ peut être de type :
| Type | Description |
|------|-------------|
| `text` | Champ texte simple |
| `textarea` | Zone de texte multiligne |
| `number` | Nombre entier ou décimal |
| `boolean` | Case à cocher |
| `select` | Liste déroulante (options définies dans `source_type_fields.options`) |
| `date` | Date avec choix du calendrier (grégorien, julien, républicain) |
| `place` | Sélecteur de lieu (recherche AJAX) |
Les champs de type `date` stockent : `{ "valeur": "YYYY-MM-DD", "calendrier": "gregorien|julien|republicain" }`.
### Recherche
La recherche plein texte porte sur les colonnes générées `nom`, `prenom`, `date_evenement` et sur le JSON complet. Elle supporte :
- recherche textuelle (LIKE ou FTS selon le SGBD)
- filtre par type de source
- filtre par lieu (avec descente récursive dans la hiérarchie via CTE)
- filtre par période (`annee_debut`/`annee_fin`)
Le nombre maximum de résultats est configurable par l'administrateur (défaut : 200). Un bandeau d'avertissement s'affiche lorsque la limite est atteinte.
### Carte
Vue cartographique (Leaflet.js + OpenStreetMap) des lieux géolocalisés ayant des relevés. Chaque marqueur affiche un popup avec les sources associées au lieu, leur statut et un lien vers la recherche.
### Export GEDCOM
Le service `GedcomExportService` génère un fichier `.ged` (GEDCOM 5.5.1) à partir d'une source ou d'une sélection de relevés. Les dates sont converties en grégorien ; le calendrier julien est annoté `@#DJULIAN@` et le calendrier républicain est converti via `DateConversionService`.
### Import de relevés CSV
Un import CSV est disponible pour les relevés (endpoint `/sources/{source}/import`). Le séparateur (`;` ou `,`) et le BOM UTF-8 sont détectés automatiquement.
---
## 8. Routes
### Routes publiques
| Méthode | URL | Action |
|---------|-----|--------|
| GET | `/` | Page d'accueil |
| GET/POST | `/setup/*` | Assistant d'installation |
| GET/POST | `/2fa` | Vérification 2FA |
| GET/POST | `/login` | Connexion |
| GET/POST | `/forgot-password` | Mot de passe oublié |
### Routes authentifiées (`/`)
| Méthode | URL | Action |
|---------|-----|--------|
| GET | `/dashboard` | Tableau de bord |
| GET/PATCH/DELETE | `/profile` | Profil utilisateur |
| GET | `/lieux` | Liste des lieux (avec filtres) |
| GET | `/lieux/search` | Recherche AJAX de lieux |
| GET/POST | `/lieux/import` | Import CSV de lieux |
| GET | `/lieux/export/csv` | Export CSV de lieux |
| GET/POST | `/lieux/{lieu}/edit` | CRUD lieux |
| GET | `/sources` | Liste des sources |
| GET/POST | `/sources/{source}/membres` | Gestion des membres assignés |
| POST | `/sources/{source}/transition` | Changer le statut |
| GET | `/sources/{source}/releves` | Liste des relevés d'une source |
| GET/POST | `/sources/{source}/releves/create` | Saisie d'un relevé |
| GET/PATCH | `/releves/{releve}/edit` | Modification d'un relevé |
| GET/POST | `/sources/{source}/import` | Import CSV de relevés |
| GET | `/export/source/{source}/csv` | Export CSV d'une source |
| GET | `/recherche` | Recherche plein texte |
| GET | `/carte` | Vue cartographique |
| GET | `/carte/data` | Données JSON pour la carte |
| GET | `/notifications` | Centre de notifications |
### Routes d'administration (`/admin`, middleware `role:admin`)
| Méthode | URL | Action |
|---------|-----|--------|
| GET | `/admin/dashboard` | Tableau de bord admin |
| GET/POST | `/admin/utilisateurs` | Liste + création d'utilisateurs |
| GET/PUT/DELETE | `/admin/utilisateurs/{id}` | Modification / suppression |
| GET/POST | `/admin/utilisateurs/export` | Export CSV utilisateurs |
| GET/POST | `/admin/utilisateurs/import` | Import CSV utilisateurs |
| POST | `/admin/utilisateurs/{id}/toggle-active` | Activer / désactiver un compte |
| GET/POST/PUT/DELETE | `/admin/lieu-types` | CRUD types de lieux |
| GET/POST/PUT/DELETE | `/admin/sections` | CRUD sections |
| GET/POST/DELETE | `/admin/sections/{id}/membres` | Membres d'une section |
| GET/POST/PUT/DELETE | `/admin/depots` | CRUD dépôts d'archives |
| GET/POST/PUT/DELETE | `/admin/source-types` | CRUD types de sources |
| POST/PUT/DELETE | `/admin/source-types/{id}/fields` | Gestion des champs dynamiques |
| POST | `/admin/source-types/{id}/fields/reorder` | Réordonner les champs |
| GET/POST | `/admin/parametres` | Paramètres du site |
| POST | `/admin/parametres/logo` | Upload du logo |
| POST | `/admin/parametres/smtp` | Configuration SMTP |
| POST | `/admin/parametres/smtp/test` | Test SMTP |
| POST | `/admin/parametres/storage-link` | Recréer le lien de stockage |
---
## 9. Services
### `DateConversionService`
Convertit les dates entre calendriers pour la génération GEDCOM.
| Méthode | Description |
|---------|-------------|
| `toGedcomDate(array)` | Convertit un champ date JSONB `{ valeur, calendrier }` en chaîne GEDCOM |
| `gregorianToGedcom(string)` | `YYYY-MM-DD``D MON YYYY` |
| `republicanToGregorian(string)` | `"15 Vendémiaire An III"``"1794-10-06"` |
Supporte les An I à XIV du calendrier républicain (17921806), avec les noms de mois accentués ou non.
### `GedcomExportService`
Génère un fichier GEDCOM 5.5.1 à partir de relevés.
| Méthode | Description |
|---------|-------------|
| `exportSource(Source)` | Exporte tous les relevés d'une source |
| `exportReleves(Collection, string)` | Exporte une sélection de relevés |
Détecte automatiquement le type d'événement (BIRT, MARR, DEAT) depuis le nom du type de source.
### `SiteSettingsService`
Persistance des paramètres du site dans `storage/app/site_settings.json`. Interface statique.
| Méthode | Description |
|---------|-------------|
| `get(key, default)` | Lire un paramètre |
| `set(key, value)` | Écrire un paramètre |
| `all()` | Tous les paramètres |
| `logoUrl()` | URL publique du logo (ou null) |
| `siteName()` | Nom du site |
| `smtpConfigured()` | SMTP configuré ? |
| `searchMaxResults()` | Limite de résultats de recherche (défaut : 200, min : 10) |
**Paramètres gérés :** `site_name`, `logo_path`, `allow_registration`, `search_max_results`, `smtp.*`, `update.*`
### `UpdateService`
Gère la vérification et l'application des mises à jour depuis le serveur de releases (Gitea). Utilisé par les commandes Artisan `app:check-update` et `app:update`.
### `DbCompat`
Classe utilitaire pour la compatibilité MySQL/PostgreSQL. Fournit des fragments SQL adaptés au SGBD actif.
| Méthode | Description |
|---------|-------------|
| `isPgsql()` | Détecte si le SGBD est PostgreSQL |
| `like()` | `ILIKE` (pgsql) ou `LIKE` (mysql) |
| `ftsRaw()` | Expression de recherche plein texte native |
| `generatedJsonCol(key)` | Expression SQL pour colonne générée JSON |
| `jsonRegexRaw(col)` | Expression regex sur JSON |
---
## 10. Énumérations
### `UserRole`
| Cas | Valeur | Label |
|-----|--------|-------|
| `Admin` | `admin` | Administrateur |
| `SectionManager` | `section_manager` | Responsable de section |
| `Member` | `member` | Membre |
### `SourceStatus`
| Cas | Valeur | Label | Transitions possibles |
|-----|--------|-------|-----------------------|
| `AFaire` | `a_faire` | À faire | → `en_cours` |
| `EnCours` | `en_cours` | En cours | → `a_valider` |
| `AValider` | `a_valider` | À valider | → `termine`, → `en_cours` |
| `Termine` | `termine` | Terminé | (aucune) |
### `CalendarType`
| Cas | Valeur | Label |
|-----|--------|-------|
| `Gregorien` | `gregorien` | Grégorien |
| `Julien` | `julien` | Julien |
| `Republicain` | `republicain` | Républicain |
### `FieldType`
`text`, `date`, `boolean`, `select`, `textarea`, `number`, `place`
---
## 11. Middlewares
### `CheckInstallation`
Redirige vers `/setup` si le fichier `storage/installed` est absent. Redirige vers `/` si l'installation est déjà effectuée et qu'on accède à `/setup`.
### `RoleMiddleware`
Usage : `role:admin` ou `role:section_manager`. Les administrateurs ont accès à toutes les routes quel que soit le rôle requis. Retourne HTTP 403 si le rôle est insuffisant.
### `EnsureUserIsActive`
Déconnecte automatiquement un utilisateur dont `is_active = false` et affiche un message d'erreur.
---
## 12. Administration
L'interface d'administration (`/admin`) est réservée aux utilisateurs de rôle `admin`.
### Gestion des utilisateurs
- Liste avec filtres (rôle, statut, recherche textuelle)
- Création manuelle (nom, e-mail, mot de passe, rôle)
- Modification (nom, e-mail, mot de passe optionnel, rôle)
- Suppression (protégée : impossible de supprimer le dernier admin ou son propre compte)
- Activation / désactivation (protégée : impossible de désactiver le dernier admin actif)
- Export CSV (avec les filtres actifs)
- Import CSV : colonnes `name`, `email`, `role`, `is_active` (optionnel) ; mots de passe temporaires générés et affichés une seule fois
### Paramètres du site
| Paramètre | Description |
|-----------|-------------|
| Nom du site | Affiché dans le titre et le header |
| Logo | Upload PNG/JPG/SVG (stocké dans `storage/app/public/site/`) |
| Inscriptions | Autoriser ou non les auto-inscriptions |
| Limite de résultats | Nombre maximum de résultats de recherche (105000, défaut 200) |
| SMTP | Serveur, port, chiffrement, identifiants (avec test d'envoi) |
| Mises à jour | Configuration de la vérification automatique |
| Lien de stockage | Recrée le symlink `public/storage → storage/app/public` |
### Tableau de bord admin
Statistiques globales : nombre d'utilisateurs, sections, sources par statut, relevés, lieux.
---
## 13. Mises à jour
Le système de mise à jour récupère les releases depuis un serveur Gitea et applique automatiquement :
1. Téléchargement de l'archive `.zip` de la release
2. Extraction et copie des fichiers
3. `composer install --no-dev`
4. `php artisan migrate --force`
5. `php artisan config:clear && php artisan view:clear`
### Commandes
```bash
php artisan app:check-update # Vérifie si une mise à jour est disponible
php artisan app:check-update --force # Ignore le cache
php artisan app:update # Télécharge et applique la mise à jour
php artisan app:update --check # Vérifie sans appliquer
php artisan app:update --force # Sans confirmation interactive
```
---
## 14. Commandes Artisan
| Commande | Description |
|----------|-------------|
| `app:check-update` | Vérifie la disponibilité d'une mise à jour |
| `app:update` | Applique la dernière mise à jour |
| `php artisan migrate` | Applique les migrations en attente |
| `php artisan migrate:fresh --seed` | Recrée la base avec données de test |
| `php artisan view:clear` | Vide le cache des vues compilées |
| `php artisan config:clear` | Vide le cache de configuration |
| `php artisan storage:link` | Crée le lien symbolique public/storage |
| `php artisan test` | Lance la suite de tests |
| `./vendor/bin/pint` | Formatage du code PHP (Laravel Pint) |
| `./vendor/bin/phpstan analyse` | Analyse statique |
---
## 15. Performance et indexation
### Index sur `releves`
| Index | Type | Colonne(s) |
|-------|------|-----------|
| `releves_nom_idx` | B-tree | `nom` (colonne générée) |
| `releves_prenom_idx` | B-tree | `prenom` (colonne générée) |
| `releves_date_evenement_idx` | B-tree | `date_evenement` (colonne générée) |
| `releves_data_gin_idx` | GIN (PostgreSQL) | `data` (JSONB complet) |
### Bonnes pratiques appliquées
- **Eager loading** systématique (`with()`) pour éviter les requêtes N+1
- **Pagination obligatoire** (25 par page par défaut, jamais de SELECT sans LIMIT)
- **Chargement différé des listes** : les pages Lieux et Sources n'affichent aucun résultat sans filtre actif (prévient les requêtes portant sur des dizaines de milliers d'enregistrements)
- **Cache Redis** (TTL 1h) sur les listes fréquemment consultées
- **Recherche par lieu** : CTE récursive pour descendre dans la hiérarchie des lieux sans jointures multiples
### Stockage des dates
Chaque champ de type `date` dans le JSONB stocke un objet :
```json
{
"valeur": "1792-09-22",
"calendrier": "gregorien"
}
```
La colonne générée `date_evenement` extrait `date_evenement.valeur` pour permettre les tris et filtres sans parsing JSON à chaque requête.
+19
View File
@@ -0,0 +1,19 @@
{
"Already registered?": "Déjà inscrit ?",
"A new verification link has been sent to the email address you provided during registration.": "Un nouveau lien de vérification a été envoyé à l'adresse e-mail fournie lors de l'inscription.",
"Confirm": "Confirmer",
"Confirm Password": "Confirmer le mot de passe",
"Email": "Adresse e-mail",
"Email Password Reset Link": "Envoyer le lien de réinitialisation",
"Forgot your password?": "Mot de passe oublié ?",
"Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.": "Mot de passe oublié ? Indiquez votre adresse e-mail et nous vous enverrons un lien pour en choisir un nouveau.",
"Log in": "Se connecter",
"Log Out": "Se déconnecter",
"Name": "Nom",
"Password": "Mot de passe",
"Register": "S'inscrire",
"Remember me": "Se souvenir de moi",
"Resend Verification Email": "Renvoyer l'e-mail de vérification",
"Reset Password": "Réinitialiser le mot de passe",
"This is a secure area of the application. Please confirm your password before continuing.": "Zone sécurisée. Veuillez confirmer votre mot de passe avant de continuer."
}
+9
View File
@@ -4,6 +4,9 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": {
"leaflet": "^1.9.4"
},
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
@@ -2276,6 +2279,12 @@
"vite": "^7.0.0" "vite": "^7.0.0"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+3
View File
@@ -17,5 +17,8 @@
"postcss": "^8.4.31", "postcss": "^8.4.31",
"tailwindcss": "^3.1.0", "tailwindcss": "^3.1.0",
"vite": "^7.0.7" "vite": "^7.0.7"
},
"dependencies": {
"leaflet": "^1.9.4"
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
# ── Sécurité ────────────────────────────────────────────────────────────────── # ── Sécurité ──────────────────────────────────────────────────────────────────
Options -Indexes -MultiViews Options -Indexes -MultiViews +FollowSymLinks
# ── En-têtes HTTP transmis à PHP ─────────────────────────────────────────────── # ── En-têtes HTTP transmis à PHP ───────────────────────────────────────────────
# Nécessaire pour que Laravel reçoive le token Authorization (API) et CSRF # Nécessaire pour que Laravel reçoive le token Authorization (API) et CSRF
+16
View File
@@ -0,0 +1,16 @@
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Correction des icônes Leaflet avec Vite (les chemins par défaut ne fonctionnent pas avec bundler)
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
});
window.LeafletMap = L;
@@ -37,6 +37,24 @@
@enderror @enderror
</div> </div>
{{-- Nombre maximum de résultats de recherche --}}
<div class="pt-4 border-t border-gray-100 dark:border-gray-700">
<label for="search_max_results" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nombre maximum de résultats de recherche
</label>
<input type="number" id="search_max_results" name="search_max_results"
value="{{ old('search_max_results', \App\Services\SiteSettingsService::get('search_max_results', 200)) }}"
min="10" max="5000"
class="block w-32 rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm
focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
La page de recherche affiche au plus ce nombre de relevés. Si la limite est atteinte, un message invite l'utilisateur à affiner ses critères. (10 5000, défaut : 200)
</p>
@error('search_max_results')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- Inscriptions --}} {{-- Inscriptions --}}
<div class="pt-4 border-t border-gray-100 dark:border-gray-700"> <div class="pt-4 border-t border-gray-100 dark:border-gray-700">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Inscription publique des comptes</p> <p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Inscription publique des comptes</p>
@@ -62,6 +80,22 @@
</form> </form>
</div> </div>
{{-- Lien de stockage public --}}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Lien de stockage</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Le lien symbolique <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">public/storage</code> permet de servir les fichiers (logo, etc.) via l'URL <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded text-xs">/storage/</code>.
S'il est absent, le logo sera invisible et d'autres fichiers seront inaccessibles.
</p>
<form method="POST" action="{{ route('admin.parametres.storage-link') }}">
@csrf
<button type="submit"
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-md hover:bg-gray-200 dark:hover:bg-gray-600">
Recréer le lien de stockage
</button>
</form>
</div>
{{-- Logo --}} {{-- Logo --}}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 space-y-5"> <div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 space-y-5">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Logo du site</h3> <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Logo du site</h3>
@@ -33,23 +33,74 @@
<ul id="fields-list" class="divide-y divide-gray-100 dark:divide-gray-700"> <ul id="fields-list" class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($sourceType->fields as $field) @foreach($sourceType->fields as $field)
<li class="px-6 py-3 flex items-center gap-4" data-id="{{ $field->id }}"> <li class="px-6 py-3" data-id="{{ $field->id }}"
<span class="cursor-move text-gray-300 dark:text-gray-600 hover:text-gray-500 select-none"></span> x-data="{
<div class="flex-1 grid grid-cols-4 gap-3 text-sm"> open: false,
<span class="font-mono text-indigo-700">{{ $field->name }}</span> type: '{{ $field->type->value }}',
<span class="text-gray-700 dark:text-gray-300">{{ $field->label }}</span> optionsRaw: {{ json_encode(collect($field->options ?? [])->join("\n")) }}
<span class="text-gray-500 dark:text-gray-400">{{ $field->type->value }}</span> }">
<span class="text-gray-400 dark:text-gray-500">{{ $field->required ? 'Obligatoire' : 'Optionnel' }}</span> <div class="flex items-center gap-4">
<span class="cursor-move text-gray-300 dark:text-gray-600 hover:text-gray-500 select-none"></span>
<div class="flex-1 grid grid-cols-4 gap-3 text-sm">
<span class="font-mono text-indigo-700">{{ $field->name }}</span>
<span class="text-gray-700 dark:text-gray-300">{{ $field->label }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ $field->type->value }}</span>
<span class="text-gray-400 dark:text-gray-500">{{ $field->required ? 'Obligatoire' : 'Optionnel' }}</span>
</div>
<div class="flex gap-2">
<button type="button" @click="open = !open"
class="text-xs text-gray-500 dark:text-gray-400 hover:text-indigo-600"
x-text="open ? 'Annuler' : 'Modifier'">Modifier</button>
<form method="POST" action="{{ route('admin.source-types.fields.destroy', [$sourceType, $field]) }}"
x-data @submit.prevent="if(confirm('Supprimer ce champ ?')) $el.submit()">
@csrf @method('DELETE')
<button type="submit" class="text-xs text-red-500 hover:text-red-700">Supprimer</button>
</form>
</div>
</div> </div>
<div class="flex gap-2">
<button type="button" {{-- Formulaire d'édition inline --}}
x-data="{ open: false }" <div x-show="open" x-cloak class="mt-3 pl-8 border-t border-gray-100 dark:border-gray-700 pt-3">
@click="open = !open" <form method="POST" action="{{ route('admin.source-types.fields.update', [$sourceType, $field]) }}">
class="text-xs text-gray-500 dark:text-gray-400 hover:text-indigo-600">Modifier</button> @csrf @method('PUT')
<form method="POST" action="{{ route('admin.source-types.fields.destroy', [$sourceType, $field]) }}" <div class="grid grid-cols-2 gap-3">
x-data @submit.prevent="if(confirm('Supprimer ce champ ?')) $el.submit()"> <div>
@csrf @method('DELETE') <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Nom technique</label>
<button type="submit" class="text-xs text-red-500 hover:text-red-700">Supprimer</button> <input type="text" name="name" value="{{ $field->name }}" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Libellé affiché</label>
<input type="text" name="label" value="{{ $field->label }}" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Type</label>
<select name="type" x-model="type"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
@foreach(\App\Enums\FieldType::cases() as $ft)
<option value="{{ $ft->value }}" {{ $field->type->value === $ft->value ? 'selected' : '' }}>{{ $ft->value }}</option>
@endforeach
</select>
<div x-show="type === 'select'" x-cloak class="mt-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Options (une par ligne)</label>
<textarea name="options_raw" rows="3" x-model="optionsRaw"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500"></textarea>
</div>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="required" value="1" {{ $field->required ? 'checked' : '' }}
class="rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500">
Champ obligatoire
</label>
</div>
</div>
<div class="mt-3">
<button type="submit" class="px-3 py-1.5 bg-indigo-600 text-white text-xs rounded-md hover:bg-indigo-700">
Enregistrer
</button>
</div>
</form> </form>
</div> </div>
</li> </li>
@@ -0,0 +1,101 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center gap-3">
<a href="{{ route('admin.utilisateurs.index') }}" class="text-sm text-indigo-600 hover:underline"> Utilisateurs</a>
<span class="text-gray-400 dark:text-gray-500">/</span>
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Nouvel utilisateur</h2>
</div>
</x-slot>
<div class="py-8 max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 space-y-5">
<form method="POST" action="{{ route('admin.utilisateurs.store') }}">
@csrf
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nom <span class="text-red-500">*</span>
</label>
<input type="text" name="name" value="{{ old('name') }}" required autofocus
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Adresse e-mail <span class="text-red-500">*</span>
</label>
<input type="email" name="email" value="{{ old('email') }}" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
@error('email')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Mot de passe <span class="text-red-500">*</span>
</label>
<input type="password" name="password" required autocomplete="new-password"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
@error('password')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmer le mot de passe <span class="text-red-500">*</span>
</label>
<input type="password" name="password_confirmation" required autocomplete="new-password"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Rôle <span class="text-red-500">*</span>
</label>
<div class="space-y-2">
@foreach(\App\Enums\UserRole::cases() as $role)
<label class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700
{{ old('role', 'member') === $role->value ? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-900/30' : 'border-gray-200 dark:border-gray-700' }}">
<input type="radio" name="role" value="{{ $role->value }}"
{{ old('role', 'member') === $role->value ? 'checked' : '' }}
class="mt-0.5 text-indigo-600">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ $role->label() }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
@if($role === \App\Enums\UserRole::Admin)
Accès complet : gestion des utilisateurs, sections, dépôts, types de sources et statistiques.
@elseif($role === \App\Enums\UserRole::SectionManager)
Peut créer des sources, assigner des membres et valider les relevés de sa section.
@else
Peut saisir des relevés sur les sources auxquelles il est assigné.
@endif
</p>
</div>
</label>
@endforeach
</div>
@error('role')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-4 pt-2">
<button type="submit"
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
Créer l'utilisateur
</button>
<a href="{{ route('admin.utilisateurs.index') }}"
class="text-sm text-gray-500 dark:text-gray-400 self-center hover:text-gray-700 dark:hover:text-gray-300">
Annuler
</a>
</div>
</form>
</div>
</div>
</x-app-layout>
+137 -59
View File
@@ -16,27 +16,110 @@
<div class="p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-800 dark:text-red-200 rounded-md">{{ session('error') }}</div> <div class="p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-800 dark:text-red-200 rounded-md">{{ session('error') }}</div>
@endif @endif
{{-- Informations --}} {{-- Formulaire principal --}}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 space-y-3"> <div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Informations</h3> <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-5">Informations</h3>
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<dt class="text-gray-500 dark:text-gray-400">Nom</dt> <form method="POST" action="{{ route('admin.utilisateurs.update', ['utilisateur' => $user]) }}">
<dd class="text-gray-900 dark:text-white font-medium">{{ $user->name }}</dd> @csrf @method('PUT')
<dt class="text-gray-500 dark:text-gray-400">E-mail</dt>
<dd class="text-gray-900 dark:text-white">{{ $user->email }}</dd> <div class="space-y-5">
<dt class="text-gray-500 dark:text-gray-400">Inscrit le</dt> <div>
<dd class="text-gray-900 dark:text-white">{{ $user->created_at->format('d/m/Y') }}</dd> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<dt class="text-gray-500 dark:text-gray-400">Sections</dt> Nom <span class="text-red-500">*</span>
<dd class="text-gray-900 dark:text-white"> </label>
@if($user->sections->isNotEmpty()) <input type="text" name="name" value="{{ old('name', $user->name) }}" required
{{ $user->sections->pluck('nom')->join(', ') }} class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Adresse e-mail <span class="text-red-500">*</span>
</label>
<input type="email" name="email" value="{{ old('email', $user->email) }}" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
@error('email')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div x-data="{ open: false }">
<button type="button" @click="open = !open"
class="text-sm text-indigo-600 hover:underline">
<span x-show="!open">Changer le mot de passe…</span>
<span x-show="open">Annuler le changement de mot de passe</span>
</button>
<div x-show="open" x-cloak class="mt-3 space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nouveau mot de passe
</label>
<input type="password" name="password" autocomplete="new-password"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
@error('password')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmer le nouveau mot de passe
</label>
<input type="password" name="password_confirmation" autocomplete="new-password"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
</div>
</div>
{{-- Rôle (masqué pour soi-même) --}}
@if($user->id !== auth()->id())
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Rôle</label>
<div class="space-y-2">
@foreach(\App\Enums\UserRole::cases() as $role)
<label class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700
{{ $user->role === $role ? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-900/30' : 'border-gray-200 dark:border-gray-700' }}">
<input type="radio" name="role" value="{{ $role->value }}"
{{ $user->role === $role ? 'checked' : '' }}
class="mt-0.5 text-indigo-600">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ $role->label() }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
@if($role === \App\Enums\UserRole::Admin)
Accès complet : gestion des utilisateurs, sections, dépôts, types de sources et statistiques.
@elseif($role === \App\Enums\UserRole::SectionManager)
Peut créer des sources, assigner des membres et valider les relevés de sa section.
@else
Peut saisir des relevés sur les sources auxquelles il est assigné.
@endif
</p>
</div>
</label>
@endforeach
</div>
</div>
@else @else
{{-- Champ caché pour ne pas perdre le rôle lors du submit --}}
<input type="hidden" name="role" value="{{ $user->role->value }}">
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
Vous ne pouvez pas modifier votre propre rôle.
</p>
@endif @endif
</dd> </div>
<dt class="text-gray-500 dark:text-gray-400">Sources assignées</dt>
<dd class="text-gray-900 dark:text-white">{{ $user->sourcesAssignees->count() }}</dd> <div class="mt-6 flex gap-4">
</dl> <button type="submit"
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
Enregistrer
</button>
<a href="{{ route('admin.utilisateurs.index') }}"
class="text-sm text-gray-500 dark:text-gray-400 self-center hover:text-gray-700 dark:hover:text-gray-300">
Annuler
</a>
</div>
</form>
</div> </div>
{{-- Statut actif / inactif --}} {{-- Statut actif / inactif --}}
@@ -45,14 +128,14 @@
<p class="text-sm font-medium text-gray-900 dark:text-white">Statut du compte</p> <p class="text-sm font-medium text-gray-900 dark:text-white">Statut du compte</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5"> <p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
@if($user->is_active) @if($user->is_active)
Le compte est <span class="text-green-600 font-medium">actif</span> l'utilisateur peut se connecter et être assigné à des sources. Le compte est <span class="text-green-600 font-medium">actif</span> l'utilisateur peut se connecter.
@else @else
Le compte est <span class="text-red-600 font-medium">inactif</span> l'utilisateur ne peut pas se connecter. Le compte est <span class="text-red-600 font-medium">inactif</span> l'utilisateur ne peut pas se connecter.
@endif @endif
</p> </p>
</div> </div>
@if($user->id !== auth()->id()) @if($user->id !== auth()->id())
<form method="POST" action="{{ route('admin.utilisateurs.toggle-active', $user) }}" <form method="POST" action="{{ route('admin.utilisateurs.toggle-active', ['utilisateur' => $user]) }}"
x-data x-data
@submit.prevent="if(confirm('{{ $user->is_active ? 'Désactiver' : 'Activer' }} ce compte ?')) $el.submit()"> @submit.prevent="if(confirm('{{ $user->is_active ? 'Désactiver' : 'Activer' }} ce compte ?')) $el.submit()">
@csrf @csrf
@@ -67,44 +150,39 @@
@endif @endif
</div> </div>
{{-- Modifier le rôle --}} {{-- Informations complémentaires --}}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6"> <div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 space-y-2">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">Rôle</h3> <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Détails</h3>
<form method="POST" action="{{ route('admin.utilisateurs.update', $user) }}"> <dl class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
@csrf @method('PUT') <dt class="text-gray-500 dark:text-gray-400">Inscrit le</dt>
<div class="space-y-3"> <dd class="text-gray-900 dark:text-white">{{ $user->created_at->format('d/m/Y') }}</dd>
@foreach(\App\Enums\UserRole::cases() as $role) <dt class="text-gray-500 dark:text-gray-400">Sections</dt>
<label class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 <dd class="text-gray-900 dark:text-white">
{{ $user->role === $role ? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-900/30' : 'border-gray-200 dark:border-gray-700' }}"> {{ $user->sections->isNotEmpty() ? $user->sections->pluck('nom')->join(', ') : '—' }}
<input type="radio" name="role" value="{{ $role->value }}" </dd>
{{ $user->role === $role ? 'checked' : '' }} <dt class="text-gray-500 dark:text-gray-400">Sources assignées</dt>
class="mt-0.5 text-indigo-600"> <dd class="text-gray-900 dark:text-white">{{ $user->sourcesAssignees->count() ?: '—' }}</dd>
<div> </dl>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ $role->label() }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
@if($role === \App\Enums\UserRole::Admin)
Accès complet : gestion des utilisateurs, sections, dépôts, types de sources et statistiques.
@elseif($role === \App\Enums\UserRole::SectionManager)
Peut créer des sources, assigner des membres et valider les relevés de sa section.
@else
Peut saisir des relevés sur les sources auxquelles il est assigné.
@endif
</p>
</div>
</label>
@endforeach
</div>
<div class="mt-5 flex gap-4">
<button type="submit"
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
Enregistrer
</button>
<a href="{{ route('admin.utilisateurs.index') }}"
class="text-sm text-gray-500 dark:text-gray-400 self-center hover:text-gray-700 dark:hover:text-gray-300">
Annuler
</a>
</div>
</form>
</div> </div>
{{-- Zone danger : suppression --}}
@if($user->id !== auth()->id())
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 border border-red-200 dark:border-red-800">
<h3 class="text-sm font-semibold text-red-700 dark:text-red-400 uppercase tracking-wide mb-2">Zone dangereuse</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
La suppression est définitive. Les relevés et assignations liés à cet utilisateur seront également supprimés.
</p>
<form method="POST" action="{{ route('admin.utilisateurs.destroy', ['utilisateur' => $user]) }}"
x-data
@submit.prevent="if(confirm('Supprimer définitivement {{ addslashes($user->name) }} ? Cette action est irréversible.')) $el.submit()">
@csrf @method('DELETE')
<button type="submit"
class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">
Supprimer cet utilisateur
</button>
</form>
</div>
@endif
</div> </div>
</x-app-layout> </x-app-layout>
@@ -86,7 +86,7 @@
</div> </div>
@endif @endif
<div class="p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800 dark:text-blue-200 space-y-1"> <div class="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg text-sm text-blue-800 dark:text-blue-200 space-y-1">
<p class="font-semibold">Format attendu du fichier CSV :</p> <p class="font-semibold">Format attendu du fichier CSV :</p>
<ul class="list-disc list-inside text-xs space-y-0.5"> <ul class="list-disc list-inside text-xs space-y-0.5">
<li>Séparateur : <code class="bg-blue-100 dark:bg-blue-900/50 px-1 rounded">;</code> ou <code class="bg-blue-100 dark:bg-blue-900/50 px-1 rounded">,</code></li> <li>Séparateur : <code class="bg-blue-100 dark:bg-blue-900/50 px-1 rounded">;</code> ou <code class="bg-blue-100 dark:bg-blue-900/50 px-1 rounded">,</code></li>
@@ -3,6 +3,10 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Gestion des utilisateurs</h2> <h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Gestion des utilisateurs</h2>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="{{ route('admin.utilisateurs.create') }}"
class="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 transition-colors">
+ Nouvel utilisateur
</a>
<a href="{{ route('admin.utilisateurs.import') }}" <a href="{{ route('admin.utilisateurs.import') }}"
class="flex items-center gap-1.5 px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"> class="flex items-center gap-1.5 px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -139,19 +143,28 @@
{{ $user->created_at->format('d/m/Y') }} {{ $user->created_at->format('d/m/Y') }}
</td> </td>
<td class="px-6 py-4 text-right space-x-3"> <td class="px-6 py-4 text-right space-x-3">
<a href="{{ route('admin.utilisateurs.edit', ['utilisateur' => $user]) }}"
class="text-indigo-600 hover:underline text-sm">Modifier</a>
@if($user->id !== auth()->id()) @if($user->id !== auth()->id())
<a href="{{ route('admin.utilisateurs.edit', $user) }}"
class="text-indigo-600 hover:underline text-sm">Modifier</a>
<form method="POST" <form method="POST"
action="{{ route('admin.utilisateurs.toggle-active', $user) }}" action="{{ route('admin.utilisateurs.toggle-active', ['utilisateur' => $user]) }}"
class="inline" x-data class="inline" x-data
@submit.prevent="if(confirm('{{ $user->is_active ? 'Désactiver' : 'Activer' }} ce compte ?')) $el.submit()"> @submit.prevent="if(confirm('{{ $user->is_active ? 'Désactiver' : 'Activer' }} ce compte ?')) $el.submit()">
@csrf @csrf
<button type="submit" <button type="submit"
class="text-sm {{ $user->is_active ? 'text-red-500 hover:text-red-700' : 'text-green-600 hover:text-green-700' }}"> class="text-sm {{ $user->is_active ? 'text-orange-500 hover:text-orange-700' : 'text-green-600 hover:text-green-700' }}">
{{ $user->is_active ? 'Désactiver' : 'Activer' }} {{ $user->is_active ? 'Désactiver' : 'Activer' }}
</button> </button>
</form> </form>
<form method="POST"
action="{{ route('admin.utilisateurs.destroy', ['utilisateur' => $user]) }}"
class="inline" x-data
@submit.prevent="if(confirm('Supprimer définitivement {{ addslashes($user->name) }} ?')) $el.submit()">
@csrf @method('DELETE')
<button type="submit" class="text-sm text-red-500 hover:text-red-700">
Supprimer
</button>
</form>
@endif @endif
</td> </td>
</tr> </tr>
+4 -4
View File
@@ -5,11 +5,11 @@
<form method="POST" action="{{ route('login') }}"> <form method="POST" action="{{ route('login') }}">
@csrf @csrf
<!-- Email Address --> <!-- Email ou nom d'utilisateur -->
<div> <div>
<x-input-label for="email" :value="__('Email')" /> <x-input-label for="login" :value="__('Email ou nom d\'utilisateur')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" /> <x-text-input id="login" class="block mt-1 w-full" type="text" name="login" :value="old('login')" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" /> <x-input-error :messages="$errors->get('login')" class="mt-2" />
</div> </div>
<!-- Password --> <!-- Password -->
+7 -8
View File
@@ -7,13 +7,14 @@
</x-slot> </x-slot>
@push('head') @push('head')
{{-- Leaflet CSS --}} {{-- Leaflet (bundlé via Vite) --}}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" @vite('resources/js/carte.js')
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<style> <style>
#carte-map { #carte-map {
height: calc(100vh - 120px); height: calc(100vh - 120px);
min-height: 400px; min-height: 400px;
position: relative;
z-index: 0; /* crée un contexte d'empilement qui scelle les z-indexes internes de Leaflet */
} }
/* Popup dark mode */ /* Popup dark mode */
.dark .leaflet-popup-content-wrapper, .dark .leaflet-popup-content-wrapper,
@@ -45,13 +46,11 @@
<div id="carte-map" class="w-full"></div> <div id="carte-map" class="w-full"></div>
@push('head') @push('head')
{{-- Leaflet JS (chargé en fin de head pour ne pas bloquer le rendu) --}}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV/XN2GqM8=" crossorigin=""
defer></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const L = window.LeafletMap;
if (!L) return;
// ── Initialisation de la carte ────────────────────────────────────── // ── Initialisation de la carte ──────────────────────────────────────
const map = L.map('carte-map', { const map = L.map('carte-map', {
center: [46.5, 2.2], // centre de la France center: [46.5, 2.2], // centre de la France
+1 -1
View File
@@ -1,4 +1,4 @@
<nav x-data="{ open: false }" class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700"> <nav x-data="{ open: false }" class="relative z-40 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16"> <div class="flex justify-between h-16">
<div class="flex"> <div class="flex">
+80
View File
@@ -0,0 +1,80 @@
<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Importer des lieux</h2>
</x-slot>
<div class="py-8 max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
@if(session('error'))
<div class="p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-800 dark:text-red-200 rounded-md">
{{ session('error') }}
</div>
@endif
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<form method="POST" action="{{ route('lieux.import.store') }}" enctype="multipart/form-data">
@csrf
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Fichier CSV <span class="text-red-500">*</span>
</label>
<input type="file" name="fichier" accept=".csv,.txt" required
class="block w-full text-sm text-gray-700 dark:text-gray-300
file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0
file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700
hover:file:bg-indigo-100 dark:file:bg-indigo-900/30 dark:file:text-indigo-300">
@error('fichier')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mt-5">
<button type="submit"
class="px-5 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm">
Importer
</button>
</div>
</form>
</div>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-5 text-sm space-y-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Format attendu</p>
<ul class="text-gray-600 dark:text-gray-400 space-y-1 list-disc list-inside">
<li>Encodage <strong>UTF-8</strong>, séparateur <strong>point-virgule</strong> (<code>;</code>) ou virgule (<code>,</code>)</li>
<li>Première ligne = en-têtes (noms exacts ci-dessous)</li>
<li>Le parent est identifié par son <strong>nom long complet</strong> (ex : <code>Gironde, France</code>)</li>
<li>Le type est identifié par son <strong>nom exact</strong> tel que défini dans les types de lieux</li>
</ul>
<div class="mt-3 overflow-x-auto">
<table class="text-xs border-collapse w-full">
<thead>
<tr class="bg-gray-200 dark:bg-gray-600">
@foreach(['nom *', 'code', 'type', 'lieu_parent', 'latitude', 'longitude', 'note'] as $col)
<th class="border border-gray-300 dark:border-gray-500 px-2 py-1 text-left font-mono text-gray-700 dark:text-gray-200">{{ $col }}</th>
@endforeach
</tr>
</thead>
<tbody>
<tr class="text-gray-500 dark:text-gray-400">
<td class="border border-gray-300 dark:border-gray-500 px-2 py-1">Bordeaux</td>
<td class="border border-gray-300 dark:border-gray-500 px-2 py-1">33063</td>
<td class="border border-gray-300 dark:border-gray-500 px-2 py-1">Commune</td>
<td class="border border-gray-300 dark:border-gray-500 px-2 py-1">Gironde, France</td>
<td class="border border-gray-300 dark:border-gray-500 px-2 py-1">44.8378</td>
<td class="border border-gray-300 dark:border-gray-500 px-2 py-1">-0.5792</td>
<td class="border border-gray-300 dark:border-gray-500 px-2 py-1"></td>
</tr>
</tbody>
</table>
</div>
<p class="text-gray-500 dark:text-gray-400 text-xs mt-2">
Astuce : utilisez
<a href="{{ route('lieux.export.csv') }}" class="text-indigo-600 hover:underline">l'export CSV</a>
de la liste des lieux comme modèle.
</p>
</div>
<a href="{{ route('lieux.index') }}" class="text-sm text-indigo-600 hover:underline"> Retour aux lieux</a>
</div>
</x-app-layout>
+27 -8
View File
@@ -2,12 +2,22 @@
<x-slot name="header"> <x-slot name="header">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Lieux</h2> <h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Lieux</h2>
@can('create', App\Models\Lieu::class) <div class="flex items-center gap-2">
<a href="{{ route('lieux.create') }}" <a href="{{ route('lieux.export.csv') }}"
class="px-4 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700"> class="px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
+ Nouveau lieu CSV
</a> </a>
@endcan @can('create', App\Models\Lieu::class)
<a href="{{ route('lieux.import.create') }}"
class="px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
Importer CSV
</a>
<a href="{{ route('lieux.create') }}"
class="px-4 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700">
+ Nouveau lieu
</a>
@endcan
</div>
</div> </div>
</x-slot> </x-slot>
@@ -76,7 +86,8 @@
</form> </form>
</div> </div>
{{-- Tableau --}} {{-- Tableau (uniquement si un filtre est actif) --}}
@if($lieux !== null)
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden"> <div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700"> <thead class="bg-gray-50 dark:bg-gray-700">
@@ -132,8 +143,7 @@
@empty @empty
<tr> <tr>
<td colspan="6" class="px-6 py-10 text-center text-gray-400 dark:text-gray-500"> <td colspan="6" class="px-6 py-10 text-center text-gray-400 dark:text-gray-500">
@if($hasFilters) Aucun lieu ne correspond aux filtres. Aucun lieu ne correspond aux filtres.
@else Aucun lieu enregistré. @endif
</td> </td>
</tr> </tr>
@endforelse @endforelse
@@ -146,5 +156,14 @@
</div> </div>
@endif @endif
</div> </div>
@else
<div class="text-center py-16 text-gray-400 dark:text-gray-500">
<svg class="mx-auto w-12 h-12 mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p class="text-sm">Utilisez les filtres ci-dessus pour rechercher des lieux.</p>
</div>
@endif
</div> </div>
</x-app-layout> </x-app-layout>
+22 -18
View File
@@ -102,19 +102,28 @@
{{-- Résultats --}} {{-- Résultats --}}
@if($resultats !== null) @if($resultats !== null)
<div> <div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3"> <div class="flex items-center justify-between mb-3">
@if($total === 0) <p class="text-sm text-gray-500 dark:text-gray-400">
Aucun relevé trouvé. @if($total === 0)
@else Aucun relevé trouvé.
<strong>{{ number_format($total) }}</strong> relevé{{ $total > 1 ? 's' : '' }} trouvé{{ $total > 1 ? 's' : '' }} @else
@if(request('q')) pour <em>« {{ request('q') }} »</em> @endif <strong>{{ number_format($total) }}</strong> relevé{{ $total > 1 ? 's' : '' }} trouvé{{ $total > 1 ? 's' : '' }}
@if(request('q')) pour <em>« {{ request('q') }} »</em> @endif
<a href="{{ route('export.recherche', request()->query()) }}" @endif
class="text-indigo-600 hover:underline"> </p>
Exporter en GEDCOM </div>
</a>
@endif @if($limited)
</p> <div class="mb-4 p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 rounded-md flex items-start gap-3">
<svg class="w-5 h-5 text-amber-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
<p class="text-sm text-amber-800 dark:text-amber-200">
Seuls les <strong>{{ number_format($resultats->count()) }} premiers résultats</strong> sur {{ number_format($total) }} sont affichés.
Affinez vos critères de recherche pour obtenir des résultats plus précis.
</p>
</div>
@endif
@if($resultats->isNotEmpty()) @if($resultats->isNotEmpty())
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden"> <div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
@@ -180,11 +189,6 @@
</table> </table>
</div> </div>
@if($resultats->hasPages())
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
{{ $resultats->links() }}
</div>
@endif
</div> </div>
@endif @endif
</div> </div>
+17
View File
@@ -7,6 +7,10 @@
$name = "data[{$field->name}]"; $name = "data[{$field->name}]";
$inputId = "field_{$field->name}"; $inputId = "field_{$field->name}";
$oldValue = old("data.{$field->name}", $value); $oldValue = old("data.{$field->name}", $value);
// Pour le type place : $value est soit null, soit ['id'=>…,'nom_long'=>…]
$placeId = $field->type === FieldType::Place ? ($value['id'] ?? null) : null;
$placeNomLong = $field->type === FieldType::Place ? ($value['nom_long'] ?? '') : '';
@endphp @endphp
<div class="space-y-1"> <div class="space-y-1">
@@ -75,6 +79,7 @@
{{-- Date grégorienne / julienne : input date HTML5 --}} {{-- Date grégorienne / julienne : input date HTML5 --}}
<input x-show="cal !== 'republicain'" <input x-show="cal !== 'republicain'"
:disabled="cal === 'republicain'"
type="date" name="{{ $name }}[valeur]" type="date" name="{{ $name }}[valeur]"
value="{{ $dateCal !== 'republicain' ? $dateVal : '' }}" value="{{ $dateCal !== 'republicain' ? $dateVal : '' }}"
{{ $field->required ? 'required' : '' }} {{ $field->required ? 'required' : '' }}
@@ -82,6 +87,7 @@
{{-- Date républicaine : saisie texte libre (ex: "15 Vendémiaire An III") --}} {{-- Date républicaine : saisie texte libre (ex: "15 Vendémiaire An III") --}}
<input x-show="cal === 'republicain'" x-cloak <input x-show="cal === 'republicain'" x-cloak
:disabled="cal !== 'republicain'"
type="text" name="{{ $name }}[valeur]" type="text" name="{{ $name }}[valeur]"
value="{{ $dateCal === 'republicain' ? $dateVal : '' }}" value="{{ $dateCal === 'republicain' ? $dateVal : '' }}"
placeholder="ex : 15 Vendémiaire An III" placeholder="ex : 15 Vendémiaire An III"
@@ -92,6 +98,17 @@
@enderror @enderror
@break @break
@case(FieldType::Place)
<x-lieu-picker
:name="$name"
:value="$placeId"
:display-value="$placeNomLong"
:required="$field->required"
label=""
placeholder="Rechercher un lieu…"
/>
@break
@endswitch @endswitch
@error("data.{$field->name}") @error("data.{$field->name}")
+1 -6
View File
@@ -10,12 +10,7 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="{{ route('sources.show', $source) }}" class="text-sm text-indigo-600 hover:underline"> Source</a> <a href="{{ route('sources.show', $source) }}" class="text-sm text-indigo-600 hover:underline"> Source</a>
<a href="{{ route('export.source', $source) }}" @can('create', [App\Models\Releve::class, $source])
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
title="Télécharger au format GEDCOM 5.5.1">
GEDCOM
</a>
@can('create', [App\Models\Releve::class, $source])
<a href="{{ route('sources.releves.create', $source) }}" <a href="{{ route('sources.releves.create', $source) }}"
class="px-4 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700"> class="px-4 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700">
+ Nouveau relevé + Nouveau relevé
+2
View File
@@ -42,6 +42,8 @@
<dd class="col-span-2 text-gray-900 dark:text-white"> <dd class="col-span-2 text-gray-900 dark:text-white">
@if($val === null || $val === '') @if($val === null || $val === '')
<span class="text-gray-400 dark:text-gray-500"></span> <span class="text-gray-400 dark:text-gray-500"></span>
@elseif(is_array($val) && isset($val['nom_long']))
{{ $val['nom_long'] }}
@elseif(is_array($val)) @elseif(is_array($val))
{{ $val['valeur'] ?? '—' }} {{ $val['valeur'] ?? '—' }}
@if(!empty($val['calendrier']) && $val['calendrier'] !== 'gregorien') @if(!empty($val['calendrier']) && $val['calendrier'] !== 'gregorien')
+8 -1
View File
@@ -16,7 +16,14 @@
<div class="text-center mb-8"> <div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-blue-600 text-white text-2xl font-bold mb-3 select-none">M</div> <div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-blue-600 text-white text-2xl font-bold mb-3 select-none">M</div>
<h1 class="text-2xl font-bold text-slate-800">MesRelevés</h1> <h1 class="text-2xl font-bold text-slate-800">MesRelevés</h1>
<p class="text-slate-500 text-sm mt-1">Assistant d'installation</p> <p class="text-slate-500 text-sm mt-1">
Assistant d'installation
@php $version = trim(@file_get_contents(base_path('VERSION')) ?: ''); @endphp
@if($version)
<span class="mx-1.5 text-slate-300">·</span>
<span class="font-mono text-slate-400">v{{ $version }}</span>
@endif
</p>
</div> </div>
{{-- Indicateur d'étapes --}} {{-- Indicateur d'étapes --}}
+84
View File
@@ -0,0 +1,84 @@
<x-app-layout>
<x-slot name="header">
<div>
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Importer des relevés</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Source : <a href="{{ route('sources.show', $source) }}" class="text-indigo-600 hover:underline">{{ $source->nom }}</a>
</p>
</div>
</x-slot>
<div class="py-8 max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
@if(session('error'))
<div class="p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-800 dark:text-red-200 rounded-md">
{{ session('error') }}
</div>
@endif
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 space-y-5">
<form method="POST" action="{{ route('sources.import.store', $source) }}" enctype="multipart/form-data">
@csrf
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Fichier CSV <span class="text-red-500">*</span>
</label>
<input type="file" name="fichier" accept=".csv,.txt" required
class="block w-full text-sm text-gray-700 dark:text-gray-300
file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0
file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700
hover:file:bg-indigo-100 dark:file:bg-indigo-900/30 dark:file:text-indigo-300">
@error('fichier')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mt-5">
<button type="submit"
class="px-5 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm">
Importer
</button>
</div>
</form>
</div>
{{-- Format attendu --}}
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-5 text-sm space-y-3">
<p class="font-medium text-gray-700 dark:text-gray-300">Format attendu</p>
<ul class="text-gray-600 dark:text-gray-400 space-y-1 list-disc list-inside">
<li>Encodage <strong>UTF-8</strong>, séparateur <strong>point-virgule</strong> (<code>;</code>) ou virgule (<code>,</code>)</li>
<li>Première ligne = en-têtes correspondant aux <strong>libellés</strong> des champs</li>
<li>Dates au format <code>AAAA-MM-JJ</code>, ou <code>AAAA-MM-JJ (calendrier)</code> pour julien/républicain</li>
<li>Booléens : <code>Oui</code> / <code>Non</code></li>
<li>Lieux : nom long du lieu (ex : <code>Bordeaux, Gironde, France</code>)</li>
</ul>
@if($source->releves->isNotEmpty() ?? $source->releves()->exists())
<p class="text-gray-500 dark:text-gray-400 text-xs mt-2">
Astuce : utilisez
<a href="{{ route('export.source.csv', $source) }}" class="text-indigo-600 hover:underline">l'export CSV</a>
d'une source existante comme modèle.
</p>
@endif
@if($source->sourceType->fields->isNotEmpty())
<div class="mt-3">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Colonnes attendues pour ce type de source</p>
<div class="flex flex-wrap gap-2">
@foreach($source->sourceType->fields->sortBy('order') as $field)
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-white dark:bg-gray-600 border border-gray-200 dark:border-gray-500 text-xs text-gray-700 dark:text-gray-300">
{{ $field->label }}
@if($field->required)
<span class="text-red-500">*</span>
@endif
<span class="text-gray-400 dark:text-gray-500">({{ $field->type->value }})</span>
</span>
@endforeach
</div>
</div>
@endif
</div>
<a href="{{ route('sources.show', $source) }}" class="text-sm text-indigo-600 hover:underline"> Retour à la source</a>
</div>
</x-app-layout>
+9
View File
@@ -97,6 +97,14 @@
</div> </div>
{{-- Tableau --}} {{-- Tableau --}}
@if($sources === null)
<div class="text-center py-16 text-gray-400 dark:text-gray-500">
<svg class="mx-auto w-12 h-12 mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-sm">Utilisez les filtres ci-dessus pour afficher les sources.</p>
</div>
@else
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden"> <div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-700"> <thead class="bg-gray-50 dark:bg-gray-700">
@@ -165,5 +173,6 @@
<div class="px-6 py-4 border-t">{{ $sources->links() }}</div> <div class="px-6 py-4 border-t">{{ $sources->links() }}</div>
@endif @endif
</div> </div>
@endif
</div> </div>
</x-app-layout> </x-app-layout>
+18 -6
View File
@@ -134,12 +134,24 @@
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden"> <div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between"> <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="font-medium text-gray-900 dark:text-white">Relevés ({{ $source->releves->count() }})</h3> <h3 class="font-medium text-gray-900 dark:text-white">Relevés ({{ $source->releves->count() }})</h3>
@can('create', [App\Models\Releve::class, $source]) <div class="flex items-center gap-2">
<a href="{{ route('sources.releves.create', $source) }}" @if($source->releves->isNotEmpty())
class="px-3 py-1.5 bg-indigo-600 text-white text-xs rounded-md hover:bg-indigo-700"> <a href="{{ route('export.source.csv', $source) }}"
+ Nouveau relevé class="px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-xs rounded-md hover:bg-gray-50 dark:hover:bg-gray-600">
</a> CSV
@endcan </a>
@endif
@can('create', [App\Models\Releve::class, $source])
<a href="{{ route('sources.import.create', $source) }}"
class="px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-xs rounded-md hover:bg-gray-50 dark:hover:bg-gray-600">
Importer CSV
</a>
<a href="{{ route('sources.releves.create', $source) }}"
class="px-3 py-1.5 bg-indigo-600 text-white text-xs rounded-md hover:bg-indigo-700">
+ Nouveau relevé
</a>
@endcan
</div>
</div> </div>
@if($source->releves->isEmpty()) @if($source->releves->isEmpty())
<p class="px-6 py-8 text-center text-gray-400 dark:text-gray-500 text-sm">Aucun relevé pour cette source.</p> <p class="px-6 py-8 text-center text-gray-400 dark:text-gray-500 text-sm">Aucun relevé pour cette source.</p>
+2 -1
View File
@@ -21,6 +21,7 @@ Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->grou
Route::delete('parametres/smtp', [SettingController::class, 'deleteSmtp'])->name('parametres.smtp.delete'); Route::delete('parametres/smtp', [SettingController::class, 'deleteSmtp'])->name('parametres.smtp.delete');
Route::post('parametres/smtp/test', [SettingController::class, 'testSmtp'])->name('parametres.smtp.test'); Route::post('parametres/smtp/test', [SettingController::class, 'testSmtp'])->name('parametres.smtp.test');
Route::post('parametres/updates', [SettingController::class, 'updateUpdates'])->name('parametres.updates'); Route::post('parametres/updates', [SettingController::class, 'updateUpdates'])->name('parametres.updates');
Route::post('parametres/storage-link', [SettingController::class, 'storageLink'])->name('parametres.storage-link');
// Routes spécifiques avant la resource pour éviter les conflits de paramètre // Routes spécifiques avant la resource pour éviter les conflits de paramètre
Route::get('utilisateurs/export', [UserController::class, 'export'])->name('utilisateurs.export'); Route::get('utilisateurs/export', [UserController::class, 'export'])->name('utilisateurs.export');
@@ -28,7 +29,7 @@ Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->grou
Route::post('utilisateurs/import', [UserController::class, 'import'])->name('utilisateurs.import.store'); Route::post('utilisateurs/import', [UserController::class, 'import'])->name('utilisateurs.import.store');
Route::get('utilisateurs/import/modele', [UserController::class, 'importTemplate'])->name('utilisateurs.import.modele'); Route::get('utilisateurs/import/modele', [UserController::class, 'importTemplate'])->name('utilisateurs.import.modele');
Route::resource('utilisateurs', UserController::class)->only(['index', 'edit', 'update']); Route::resource('utilisateurs', UserController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy']);
Route::post('utilisateurs/{utilisateur}/toggle-active', [UserController::class, 'toggleActive'])->name('utilisateurs.toggle-active'); Route::post('utilisateurs/{utilisateur}/toggle-active', [UserController::class, 'toggleActive'])->name('utilisateurs.toggle-active');
Route::resource('lieu-types', LieuTypeController::class) Route::resource('lieu-types', LieuTypeController::class)
->parameters(['lieu-types' => 'lieuType']) ->parameters(['lieu-types' => 'lieuType'])
+8 -3
View File
@@ -3,6 +3,7 @@
use App\Http\Controllers\CarteController; use App\Http\Controllers\CarteController;
use App\Http\Controllers\DashboardController; use App\Http\Controllers\DashboardController;
use App\Http\Controllers\ExportController; use App\Http\Controllers\ExportController;
use App\Http\Controllers\ImportController;
use App\Http\Controllers\LieuController; use App\Http\Controllers\LieuController;
use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
@@ -45,7 +46,10 @@ Route::middleware('auth')->group(function () {
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('lieux/search', [LieuController::class, 'search'])->name('lieux.search'); Route::get('lieux/search', [LieuController::class, 'search'])->name('lieux.search');
Route::get('lieux/export/csv', [LieuController::class, 'exportCsv'])->name('lieux.export.csv');
Route::get('lieux/import', [LieuController::class, 'importCreate'])->name('lieux.import.create');
Route::post('lieux/import', [LieuController::class, 'importStore'])->name('lieux.import.store');
Route::resource('lieux', LieuController::class)->parameters(['lieux' => 'lieu']); Route::resource('lieux', LieuController::class)->parameters(['lieux' => 'lieu']);
Route::resource('sources', SourceController::class); Route::resource('sources', SourceController::class);
@@ -60,8 +64,9 @@ Route::middleware('auth')->group(function () {
Route::get('recherche', [RechercheController::class, 'index'])->name('recherche'); Route::get('recherche', [RechercheController::class, 'index'])->name('recherche');
Route::get('carte', [CarteController::class, 'index'])->name('carte'); Route::get('carte', [CarteController::class, 'index'])->name('carte');
Route::get('carte/data', [CarteController::class, 'data'])->name('carte.data'); Route::get('carte/data', [CarteController::class, 'data'])->name('carte.data');
Route::get('export/source/{source}', [ExportController::class, 'source'])->name('export.source'); Route::get('export/source/{source}/csv', [ExportController::class, 'sourceCsv'])->name('export.source.csv');
Route::get('export/recherche', [ExportController::class, 'recherche'])->name('export.recherche'); Route::get('sources/{source}/import', [ImportController::class, 'create'])->name('sources.import.create');
Route::post('sources/{source}/import', [ImportController::class, 'store'])->name('sources.import.store');
Route::get('notifications', [NotificationController::class, 'index'])->name('notifications.index'); Route::get('notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::post('notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.read'); Route::post('notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.read');
+1 -1
View File
@@ -4,7 +4,7 @@ import laravel from 'laravel-vite-plugin';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.js'], input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/carte.js'],
refresh: true, refresh: true,
}), }),
], ],