21 Commits

Author SHA1 Message Date
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
34 changed files with 790 additions and 203 deletions
+41 -3
View File
@@ -5,6 +5,44 @@ 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é
@@ -13,7 +51,7 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
### Corrigé
- **Assistant d'installation — conflit pgsql/mysql (cause racine)** : lors d'un démarrage sur serveur vierge, `public/index.php` auto-crée un `.env` depuis `.env.example` (driver `pgsql`) et Laravel charge cet env via `putenv()` au niveau OS. Les sous-processus `exec()` héritent cet env ; `phpdotenv` en mode immutable refuse d'écraser une variable déjà présente dans l'env OS → le `.env` réécrit avec `DB_CONNECTION=mysql` par le wizard était ignoré par le subprocess de migration, qui tentait une connexion PostgreSQL. Correction : les migrations sont maintenant exécutées via `Artisan::call()` dans le processus courant, après reconfiguration explicite en mémoire (`config(['database.default' => ...]`) + `DB::purge()` — aucun héritage d'env parasite.
- **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é.
---
@@ -22,8 +60,8 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
### Corrigé
- **Assistant d'installation — conflit de connexion pgsql/mysql** : un fichier `bootstrap/cache/config.php` résiduel (produit par `php artisan optimize` sur le poste de développement) était lu en priorité sur le `.env` réécrit par le wizard, forçant une connexion PostgreSQL même quand MySQL était sélectionné. Un appel à `config:clear` est maintenant effectué entre l'écriture du `.env` et l'exécution des migrations.
- **Archives de déploiement** : `bootstrap/cache/*.php` exclu du build rsync pour éviter qu'un cache de configuration de développement ne soit embarqué dans les distributables.
- **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 -1
View File
@@ -1 +1 @@
1.0.4
1.0.6
+1
View File
@@ -10,4 +10,5 @@ enum FieldType: string
case Select = 'select';
case Textarea = 'textarea';
case Number = 'number';
case Place = 'place';
}
@@ -50,12 +50,21 @@ class DashboardController extends Controller
->take(10)
->get();
// Activité mensuelle des 6 derniers mois
// Activité mensuelle des 6 derniers mois (compatible MySQL et PostgreSQL)
$driver = config('database.default');
if ($driver === 'pgsql') {
$activiteMensuelle = Releve::selectRaw("to_char(date_trunc('month', created_at), 'Mon YYYY') as mois, count(*) as total")
->where('created_at', '>=', now()->subMonths(5)->startOfMonth())
->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(
'sourcesByStatus', 'totalSources', 'totalReleves',
@@ -76,7 +76,15 @@ class SourceTypeController extends Controller
public function updateField(StoreSourceTypeFieldRequest $request, SourceType $sourceType, SourceTypeField $field): RedirectResponse
{
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.');
}
+2 -2
View File
@@ -21,8 +21,8 @@ class CarteController extends Controller
->whereHas('sources.releves')
->with([
'sources' => function ($q) {
$q->withCount('releves')
->select('id', 'nom', 'lieu_id', 'status', 'annee_debut', 'annee_fin')
$q->select('id', 'nom', 'lieu_id', 'status', 'annee_debut', 'annee_fin')
->withCount('releves')
->orderBy('nom');
},
])
+46 -86
View File
@@ -2,108 +2,68 @@
namespace App\Http\Controllers;
use App\Enums\SourceStatus;
use App\Models\Releve;
use App\Enums\FieldType;
use App\Models\Source;
use App\Services\GedcomExportService;
use App\Support\DbCompat;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class ExportController extends Controller
{
public function __construct(
private readonly GedcomExportService $gedcom,
) {}
/** Export de tous les relevés d'une source */
public function source(Source $source): Response
/** Export CSV de tous les relevés d'une source */
public function sourceCsv(Source $source): Response
{
$this->authorize('view', $source);
$gedcomContent = $this->gedcom->exportSource($source);
$filename = $this->sanitizeFilename($source->nom) . '.ged';
$source->load(['sourceType.fields', 'releves']);
$fields = $source->sourceType->fields->sortBy('order');
return response($gedcomContent, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
$handle = fopen('php://temp', 'r+');
// 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}\"",
]);
}
/** Export depuis les résultats de recherche (avec les mêmes filtres) */
public function recherche(Request $request): Response
private function formatCsvValue(mixed $val, FieldType $type): string
{
$user = auth()->user();
$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 ($val === null || $val === '') {
return '';
}
if ($request->filled('lieu_id')) {
$rows = DB::select("
WITH RECURSIVE descendants AS (
SELECT id, nom FROM lieux WHERE id = ?
UNION ALL
SELECT l.id, l.nom FROM lieux l
INNER JOIN descendants d ON l.lieu_parent_id = d.id
)
SELECT DISTINCT nom FROM descendants WHERE nom IS NOT NULL
", [$request->integer('lieu_id')]);
$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}\"",
]);
return match ($type) {
FieldType::Boolean => $val ? 'Oui' : 'Non',
FieldType::Date => is_array($val)
? trim(($val['valeur'] ?? '') . (
! empty($val['calendrier']) && $val['calendrier'] !== 'gregorien'
? ' (' . $val['calendrier'] . ')'
: ''
))
: (string) $val,
FieldType::Place => is_array($val) ? ($val['nom_long'] ?? '') : (string) $val,
default => (string) $val,
};
}
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];
}
}
+107
View File
@@ -10,6 +10,7 @@ use App\Support\DbCompat;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
@@ -129,6 +130,112 @@ class LieuController extends Controller
->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
{
$rows = DB::select("
+2 -2
View File
@@ -88,8 +88,8 @@ class RechercheController extends Controller
// ── Tri + pagination ────────────────────────────────────────────────
$total = $query->count();
$resultats = $query
->orderByRaw('nom ASC NULLS LAST')
->orderByRaw('date_evenement ASC NULLS LAST')
->orderByRaw(DbCompat::nullsLast('nom'))
->orderByRaw(DbCompat::nullsLast('date_evenement'))
->paginate(25)
->withQueryString();
+14 -7
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Enums\FieldType;
use App\Http\Requests\StoreReleveRequest;
use App\Http\Requests\UpdateReleveRequest;
use App\Models\Lieu;
use App\Models\Releve;
use App\Models\Source;
use Illuminate\Http\RedirectResponse;
@@ -49,27 +50,28 @@ class ReleveController extends Controller
->with('success', 'Relevé ajouté.');
}
public function show(Source $source, Releve $releve): View
public function show(Releve $releve): View
{
$this->authorize('view', $releve);
$source->load('sourceType.fields');
$source = $releve->source->load('sourceType.fields');
$releve->load('createur', 'modificateur');
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);
$source->load('sourceType.fields');
$source = $releve->source->load('sourceType.fields');
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);
$releve->update([
@@ -77,14 +79,15 @@ class ReleveController extends Controller
'updated_by' => $request->user()->id,
]);
return redirect()->route('sources.releves.show', [$source, $releve])
return redirect()->route('releves.show', $releve)
->with('success', 'Relevé mis à jour.');
}
public function destroy(Source $source, Releve $releve): RedirectResponse
public function destroy(Releve $releve): RedirectResponse
{
$this->authorize('delete', $releve);
$source = $releve->source;
$releve->delete();
return redirect()->route('sources.releves.index', $source)
@@ -106,6 +109,10 @@ class ReleveController extends Controller
'valeur' => $value['valeur'] ?? null,
'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,
};
}
+40 -18
View File
@@ -128,8 +128,6 @@ class SetupController extends Controller
$steps = [];
$success = true;
$php = $this->phpBinary();
$artisan = $php . ' ' . escapeshellarg(base_path('artisan'));
// 1. Écriture du .env
try {
@@ -140,24 +138,44 @@ class SetupController extends Controller
$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) {
[$ok, $out] = $this->artisanRun($artisan, 'key:generate --force');
$steps[] = ['ok' => $ok, 'label' => 'Génération de la clé de chiffrement (APP_KEY)', 'error' => $ok ? null : $out];
if (! $ok) $success = false;
try {
$appKey = 'base64:' . base64_encode(random_bytes(32));
$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;
}
}
// 2b. Reconfiguration de la connexion BDD dans le processus courant.
// 2b. Reconfiguration de la connexion BDD processus courant ET sous-processus.
//
// Problème : public/index.php charge le .env auto-créé (pgsql) au premier boot
// et appelle putenv('DB_CONNECTION=pgsql'). Les sous-processus exec() héritent
// cet env OS. phpdotenv en mode immutable (défaut Laravel) refuse d'écraser une
// variable déjà présente dans l'env → le nouveau .env (mysql) est ignoré par le
// subprocess de migration qui continue à tenter une connexion pgsql.
//
// Solution : exécuter les migrations via Artisan::call() dans le processus courant
// après avoir écrasé la config BDD en mémoire — pas de subprocess, pas d'héritage.
// 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) {
putenv("APP_KEY={$appKey}");
putenv("DB_CONNECTION={$dbData['driver']}");
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'],
@@ -214,10 +232,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) {
$this->artisanRun($artisan, 'optimize:clear');
$this->artisanRun($artisan, 'optimize');
Artisan::call('optimize:clear');
}
// 7. Marquage installation
+9 -19
View File
@@ -28,25 +28,23 @@ class LoginRequest extends FormRequest
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'login' => ['required', 'string'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$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());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
'login' => trans('auth.failed'),
]);
}
@@ -55,18 +53,13 @@ class LoginRequest extends FormRequest
RateLimiter::hit($this->throttleKey());
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());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
@@ -78,18 +71,15 @@ class LoginRequest extends FormRequest
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'login' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
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
{
$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
@@ -46,6 +46,10 @@ class StoreReleveRequest extends FormRequest
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)];
break;
case FieldType::Place:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'integer', 'exists:lieux,id'];
break;
default: // text, textarea
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
}
@@ -41,6 +41,9 @@ class UpdateReleveRequest extends FormRequest
$options = $field->options ?? [];
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)];
break;
case FieldType::Place:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'integer', 'exists:lieux,id'];
break;
default:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
}
+12
View File
@@ -58,6 +58,18 @@ class DbCompat
: "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) */
public static function generatedJsonNestedCol(string $jsonPath): string
{
+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,
"packages": {
"": {
"dependencies": {
"leaflet": "^1.9.4"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0",
@@ -2276,6 +2279,12 @@
"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": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+3
View File
@@ -17,5 +17,8 @@
"postcss": "^8.4.31",
"tailwindcss": "^3.1.0",
"vite": "^7.0.7"
},
"dependencies": {
"leaflet": "^1.9.4"
}
}
+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;
@@ -33,7 +33,13 @@
<ul id="fields-list" class="divide-y divide-gray-100 dark:divide-gray-700">
@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 }}"
x-data="{
open: false,
type: '{{ $field->type->value }}',
optionsRaw: {{ json_encode(collect($field->options ?? [])->join("\n")) }}
}">
<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>
@@ -42,16 +48,61 @@
<span class="text-gray-400 dark:text-gray-500">{{ $field->required ? 'Obligatoire' : 'Optionnel' }}</span>
</div>
<div class="flex gap-2">
<button type="button"
x-data="{ open: false }"
@click="open = !open"
class="text-xs text-gray-500 dark:text-gray-400 hover:text-indigo-600">Modifier</button>
<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>
{{-- Formulaire d'édition inline --}}
<div x-show="open" x-cloak class="mt-3 pl-8 border-t border-gray-100 dark:border-gray-700 pt-3">
<form method="POST" action="{{ route('admin.source-types.fields.update', [$sourceType, $field]) }}">
@csrf @method('PUT')
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Nom technique</label>
<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>
</div>
</li>
@endforeach
</ul>
+4 -4
View File
@@ -5,11 +5,11 @@
<form method="POST" action="{{ route('login') }}">
@csrf
<!-- Email Address -->
<!-- Email ou nom d'utilisateur -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
<x-input-label for="login" :value="__('Email ou nom d\'utilisateur')" />
<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('login')" class="mt-2" />
</div>
<!-- Password -->
+5 -8
View File
@@ -7,9 +7,8 @@
</x-slot>
@push('head')
{{-- Leaflet CSS --}}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
{{-- Leaflet (bundlé via Vite) --}}
@vite('resources/js/carte.js')
<style>
#carte-map {
height: calc(100vh - 120px);
@@ -45,13 +44,11 @@
<div id="carte-map" class="w-full"></div>
@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>
document.addEventListener('DOMContentLoaded', function () {
const L = window.LeafletMap;
if (!L) return;
// ── Initialisation de la carte ──────────────────────────────────────
const map = L.map('carte-map', {
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="flex justify-between h-16">
<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>
+10
View File
@@ -2,13 +2,23 @@
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Lieux</h2>
<div class="flex items-center gap-2">
<a href="{{ route('lieux.export.csv') }}"
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">
CSV
</a>
@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>
</x-slot>
<div class="py-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
@@ -108,11 +108,6 @@
@else
<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()) }}"
class="text-indigo-600 hover:underline">
Exporter en GEDCOM
</a>
@endif
</p>
+17
View File
@@ -7,6 +7,10 @@
$name = "data[{$field->name}]";
$inputId = "field_{$field->name}";
$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
<div class="space-y-1">
@@ -75,6 +79,7 @@
{{-- Date grégorienne / julienne : input date HTML5 --}}
<input x-show="cal !== 'republicain'"
:disabled="cal === 'republicain'"
type="date" name="{{ $name }}[valeur]"
value="{{ $dateCal !== 'republicain' ? $dateVal : '' }}"
{{ $field->required ? 'required' : '' }}
@@ -82,6 +87,7 @@
{{-- Date républicaine : saisie texte libre (ex: "15 Vendémiaire An III") --}}
<input x-show="cal === 'republicain'" x-cloak
:disabled="cal !== 'republicain'"
type="text" name="{{ $name }}[valeur]"
value="{{ $dateCal === 'republicain' ? $dateVal : '' }}"
placeholder="ex : 15 Vendémiaire An III"
@@ -92,6 +98,17 @@
@enderror
@break
@case(FieldType::Place)
<x-lieu-picker
:name="$name"
:value="$placeId"
:display-value="$placeNomLong"
:required="$field->required"
label=""
placeholder="Rechercher un lieu…"
/>
@break
@endswitch
@error("data.{$field->name}")
+1 -6
View File
@@ -10,12 +10,7 @@
</div>
<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('export.source', $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])
@can('create', [App\Models\Releve::class, $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">
+ Nouveau relevé
+2
View File
@@ -42,6 +42,8 @@
<dd class="col-span-2 text-gray-900 dark:text-white">
@if($val === null || $val === '')
<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))
{{ $val['valeur'] ?? '—' }}
@if(!empty($val['calendrier']) && $val['calendrier'] !== 'gregorien')
+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>
+12
View File
@@ -134,13 +134,25 @@
<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">
<h3 class="font-medium text-gray-900 dark:text-white">Relevés ({{ $source->releves->count() }})</h3>
<div class="flex items-center gap-2">
@if($source->releves->isNotEmpty())
<a href="{{ route('export.source.csv', $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">
CSV
</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>
@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>
@else
+7 -2
View File
@@ -3,6 +3,7 @@
use App\Http\Controllers\CarteController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\ExportController;
use App\Http\Controllers\ImportController;
use App\Http\Controllers\LieuController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\ProfileController;
@@ -46,6 +47,9 @@ Route::middleware('auth')->group(function () {
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
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('sources', SourceController::class);
@@ -60,8 +64,9 @@ Route::middleware('auth')->group(function () {
Route::get('recherche', [RechercheController::class, 'index'])->name('recherche');
Route::get('carte', [CarteController::class, 'index'])->name('carte');
Route::get('carte/data', [CarteController::class, 'data'])->name('carte.data');
Route::get('export/source/{source}', [ExportController::class, 'source'])->name('export.source');
Route::get('export/recherche', [ExportController::class, 'recherche'])->name('export.recherche');
Route::get('export/source/{source}/csv', [ExportController::class, 'sourceCsv'])->name('export.source.csv');
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::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({
plugins: [
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,
}),
],