Étapes 6-9 + types de lieux + picker + filtres

- Étape 6 : formulaire de saisie dynamique des relevés (piloté par source_type_fields, calendriers grégorien/julien/républicain)
- Étape 7 : workflow de statut des sources + notifications mail+DB (SourceAValider, SourceRejetee)
- Étape 8 : recherche fulltext PostgreSQL avec filtres type/lieu/années et CTE récursive pour les subdivisions de lieux
- Étape 9 : export GEDCOM 5.5.1 (GedcomExportService + DateConversionService)
- Types de lieux : CRUD admin (LieuTypeController) avec champ ordre
- Composant lieu-picker : modale Alpine.js avec recherche AJAX + debounce
- Filtres sources : statut, type, lieu (CTE récursive), période annee_debut/annee_fin
- Filtres lieux : type, texte, lieu parent avec descendants (CTE récursive)
- Migration : lieu_id + annee_debut + annee_fin sur sources

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 17:17:53 +02:00
parent 7609d35287
commit d064f8d28e
54 changed files with 2861 additions and 116 deletions
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreLieuTypeRequest;
use App\Models\LieuType;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class LieuTypeController extends Controller
{
public function index(): View
{
$lieuTypes = LieuType::withCount('lieux')->orderBy('ordre')->get();
return view('admin.lieu-types.index', compact('lieuTypes'));
}
public function create(): View
{
return view('admin.lieu-types.create');
}
public function store(StoreLieuTypeRequest $request): RedirectResponse
{
LieuType::create($request->validated());
return redirect()->route('admin.lieu-types.index')->with('success', 'Type de lieu créé.');
}
public function edit(LieuType $lieuType): View
{
return view('admin.lieu-types.edit', compact('lieuType'));
}
public function update(StoreLieuTypeRequest $request, LieuType $lieuType): RedirectResponse
{
$lieuType->update($request->validated());
return redirect()->route('admin.lieu-types.index')->with('success', 'Type de lieu mis à jour.');
}
public function destroy(LieuType $lieuType): RedirectResponse
{
if ($lieuType->lieux()->exists()) {
return back()->with('error', 'Impossible de supprimer un type utilisé par des lieux.');
}
$lieuType->delete();
return redirect()->route('admin.lieu-types.index')->with('success', 'Type de lieu supprimé.');
}
}
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreSectionRequest;
use App\Http\Requests\Admin\UpdateSectionRequest;
use App\Models\Lieu;
use App\Models\Section;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
@@ -22,8 +21,7 @@ class SectionController extends Controller
public function create(): View
{
$lieux = Lieu::orderBy('nom_long')->get(['id', 'nom_long', 'nom']);
return view('admin.sections.create', compact('lieux'));
return view('admin.sections.create');
}
public function store(StoreSectionRequest $request): RedirectResponse
@@ -42,8 +40,8 @@ class SectionController extends Controller
public function edit(Section $section): View
{
$lieux = Lieu::orderBy('nom_long')->get(['id', 'nom_long', 'nom']);
return view('admin.sections.edit', compact('section', 'lieux'));
$section->load('lieu');
return view('admin.sections.edit', compact('section'));
}
public function update(UpdateSectionRequest $request, Section $section): RedirectResponse
+1 -1
View File
@@ -4,5 +4,5 @@ namespace App\Http\Controllers;
abstract class Controller
{
//
use \Illuminate\Foundation\Auth\Access\AuthorizesRequests;
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SourceStatus;
use App\Models\Releve;
use App\Models\Source;
use App\Services\GedcomExportService;
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
{
$this->authorize('view', $source);
$gedcomContent = $this->gedcom->exportSource($source);
$filename = $this->sanitizeFilename($source->nom) . '.ged';
return response($gedcomContent, 200, [
'Content-Type' => 'text/plain; 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
{
$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'));
$query->where(function ($wq) use ($q) {
$wq->where('nom', 'ilike', "%{$q}%")
->orWhere('prenom','ilike', "%{$q}%")
->orWhere('date_evenement', 'ilike', "%{$q}%")
->orWhereRaw(
"to_tsvector('french', data::text) @@ plainto_tsquery('french', ?)",
[$q]
);
});
}
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("data::text ~* ?", [$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
{
$name = iconv('UTF-8', 'ASCII//TRANSLIT', $name) ?: $name;
return preg_replace('/[^a-zA-Z0-9_-]/', '_', $name);
}
}
+76 -19
View File
@@ -5,30 +5,77 @@ namespace App\Http\Controllers;
use App\Http\Requests\StoreLieuRequest;
use App\Http\Requests\UpdateLieuRequest;
use App\Models\Lieu;
use App\Models\LieuType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class LieuController extends Controller
{
public function index(): View
public function search(Request $request): JsonResponse
{
$q = trim($request->get('q', ''));
$lieux = Lieu::with('lieuType')
->where(function ($query) use ($q) {
$query->where('nom_long', 'ilike', "%{$q}%")
->orWhere('nom', 'ilike', "%{$q}%")
->orWhere('code', 'ilike', "%{$q}%");
})
->orderBy('nom_long')
->limit(25)
->get();
return response()->json($lieux->map(fn ($l) => [
'id' => $l->id,
'nom_long' => $l->nom_long ?? $l->nom,
'code' => $l->code,
'type' => $l->lieuType?->nom,
]));
}
public function index(Request $request): View
{
$this->authorize('viewAny', Lieu::class);
// Arbre complet trié par nom_long pour l'affichage
$lieux = Lieu::with('parent')
->orderBy('nom_long')
->paginate(50);
$query = Lieu::with(['parent', 'lieuType'])->orderBy('nom_long');
return view('lieux.index', compact('lieux'));
if ($request->filled('lieu_type_id')) {
$query->where('lieu_type_id', $request->integer('lieu_type_id'));
}
if ($request->filled('q')) {
$q = trim($request->get('q'));
$query->where(function ($wq) use ($q) {
$wq->where('nom_long', 'ilike', "%{$q}%")
->orWhere('nom', 'ilike', "%{$q}%")
->orWhere('code', 'ilike', "%{$q}%");
});
}
if ($request->filled('lieu_id')) {
$ids = $this->getDescendantAndSelfIds($request->integer('lieu_id'));
$query->whereIn('id', $ids);
}
$lieuTypes = LieuType::orderBy('ordre')->get(['id', 'nom']);
$lieuSelectionne = $request->filled('lieu_id')
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
: null;
$lieux = $query->paginate(50)->withQueryString();
return view('lieux.index', compact('lieux', 'lieuTypes', 'lieuSelectionne'));
}
public function create(): View
{
$this->authorize('create', Lieu::class);
$parents = Lieu::orderBy('nom_long')->get(['id', 'nom_long']);
$lieuTypes = LieuType::orderBy('ordre')->get(['id', 'nom']);
return view('lieux.create', compact('parents'));
return view('lieux.create', compact('lieuTypes'));
}
public function store(StoreLieuRequest $request): RedirectResponse
@@ -43,7 +90,7 @@ class LieuController extends Controller
{
$this->authorize('view', $lieu);
$lieu->load('parent', 'enfants');
$lieu->load('parent', 'enfants', 'lieuType');
return view('lieux.show', compact('lieu'));
}
@@ -52,20 +99,15 @@ class LieuController extends Controller
{
$this->authorize('update', $lieu);
// Exclure le lieu lui-même et ses descendants pour éviter les cycles
$descendants = $this->getDescendantIds($lieu);
$parents = Lieu::whereNotIn('id', [...$descendants, $lieu->id])
->orderBy('nom_long')
->get(['id', 'nom_long']);
$lieu->load('parent', 'lieuType');
$lieuTypes = LieuType::orderBy('ordre')->get(['id', 'nom']);
return view('lieux.edit', compact('lieu', 'parents'));
return view('lieux.edit', compact('lieu', 'lieuTypes'));
}
public function update(UpdateLieuRequest $request, Lieu $lieu): RedirectResponse
{
$lieu->update($request->validated());
// Recalculer nom_long des enfants en cascade
$this->recalculerEnfants($lieu);
return redirect()->route('lieux.show', $lieu)
@@ -86,12 +128,27 @@ class LieuController extends Controller
->with('success', 'Lieu supprimé.');
}
private function getDescendantAndSelfIds(int $lieuId): array
{
$rows = DB::select("
WITH RECURSIVE descendants AS (
SELECT id FROM lieux WHERE id = ?
UNION ALL
SELECT l.id FROM lieux l
INNER JOIN descendants d ON l.lieu_parent_id = d.id
)
SELECT id FROM descendants
", [$lieuId]);
return collect($rows)->pluck('id')->toArray();
}
private function getDescendantIds(Lieu $lieu): array
{
$ids = [];
foreach ($lieu->enfants as $enfant) {
$ids[] = $enfant->id;
$ids = array_merge($ids, $this->getDescendantIds($enfant));
$ids = array_merge($ids, $this->getDescendantIds($enfant));
}
return $ids;
}
@@ -100,7 +157,7 @@ class LieuController extends Controller
{
$lieu->load('enfants');
foreach ($lieu->enfants as $enfant) {
$enfant->update([]); // déclenche le booted() hook qui recalcule nom_long
$enfant->update([]);
$this->recalculerEnfants($enfant);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$notifications = $request->user()
->notifications()
->paginate(25);
return view('notifications.index', compact('notifications'));
}
public function markAsRead(Request $request, string $id): RedirectResponse
{
$request->user()
->notifications()
->where('id', $id)
->first()
?->markAsRead();
return back();
}
public function markAllAsRead(Request $request): RedirectResponse
{
$request->user()->unreadNotifications->markAsRead();
return back()->with('success', 'Toutes les notifications ont été marquées comme lues.');
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SourceStatus;
use App\Models\Lieu;
use App\Models\Releve;
use App\Models\SourceType;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class RechercheController extends Controller
{
public function index(Request $request): View
{
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
$resultats = null;
$total = null;
// Charger le lieu sélectionné pour pré-remplir le picker
$lieuSelectionne = $request->filled('lieu_id')
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
: null;
if ($request->anyFilled(['q', 'source_type_id', 'lieu_id', 'annee_debut', 'annee_fin'])) {
[$resultats, $total] = $this->search($request);
}
return view('recherche.index', compact('sourceTypes', 'resultats', 'total', 'lieuSelectionne'));
}
private function search(Request $request): array
{
$user = auth()->user();
$query = Releve::with(['source.sourceType', 'source.depot', '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'));
}
});
// ── Recherche textuelle ──────────────────────────────────────────────
if ($request->filled('q')) {
$q = trim($request->get('q'));
$query->where(function ($wq) use ($q) {
$wq->where('nom', 'ilike', "%{$q}%")
->orWhere('prenom','ilike', "%{$q}%")
->orWhere('date_evenement', 'ilike', "%{$q}%")
->orWhereRaw(
"to_tsvector('french', data::text) @@ plainto_tsquery('french', ?)",
[$q]
);
});
}
// ── Filtre par lieu (+ descendants via CTE récursive) ────────────────
if ($request->filled('lieu_id')) {
$lieuNoms = $this->getLieuNoms($request->integer('lieu_id'));
if ($lieuNoms->isNotEmpty()) {
// Recherche regex case-insensitive dans le JSONB text
$pattern = $lieuNoms
->map(fn ($n) => preg_quote($n, '/'))
->join('|');
$query->whereRaw("data::text ~* ?", [$pattern]);
}
}
// ── Filtre par plage d'années ────────────────────────────────────────
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']);
}
// ── Tri + pagination ────────────────────────────────────────────────
$total = $query->count();
$resultats = $query
->orderByRaw('nom ASC NULLS LAST')
->orderByRaw('date_evenement ASC NULLS LAST')
->paginate(25)
->withQueryString();
return [$resultats, $total];
}
/**
* Retourne les noms du lieu et de tous ses descendants via CTE récursive PostgreSQL.
*/
private function getLieuNoms(int $lieuId): \Illuminate\Support\Collection
{
$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
", [$lieuId]);
return collect($rows)->pluck('nom')->filter();
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers;
use App\Enums\FieldType;
use App\Http\Requests\StoreReleveRequest;
use App\Http\Requests\UpdateReleveRequest;
use App\Models\Releve;
use App\Models\Source;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class ReleveController extends Controller
{
public function index(Source $source): View
{
$this->authorize('viewAny', [Releve::class, $source]);
$source->load('sourceType.fields');
// Keyset pagination sur id (évite le COUNT sur des millions de lignes)
$releves = $source->releves()
->orderBy('id')
->cursorPaginate(25);
return view('releves.index', compact('source', 'releves'));
}
public function create(Source $source): View
{
$this->authorize('create', [Releve::class, $source]);
$source->load('sourceType.fields');
return view('releves.create', compact('source'));
}
public function store(StoreReleveRequest $request, Source $source): RedirectResponse
{
$data = $this->buildData($request->validated()['data'] ?? [], $source);
$source->releves()->create([
'data' => $data,
'created_by' => $request->user()->id,
'updated_by' => $request->user()->id,
]);
return redirect()->route('sources.releves.index', $source)
->with('success', 'Relevé ajouté.');
}
public function show(Source $source, Releve $releve): View
{
$this->authorize('view', $releve);
$source->load('sourceType.fields');
$releve->load('createur', 'modificateur');
return view('releves.show', compact('source', 'releve'));
}
public function edit(Source $source, Releve $releve): View
{
$this->authorize('update', $releve);
$source->load('sourceType.fields');
return view('releves.edit', compact('source', 'releve'));
}
public function update(UpdateReleveRequest $request, Source $source, Releve $releve): RedirectResponse
{
$data = $this->buildData($request->validated()['data'] ?? [], $source);
$releve->update([
'data' => $data,
'updated_by' => $request->user()->id,
]);
return redirect()->route('sources.releves.show', [$source, $releve])
->with('success', 'Relevé mis à jour.');
}
public function destroy(Source $source, Releve $releve): RedirectResponse
{
$this->authorize('delete', $releve);
$releve->delete();
return redirect()->route('sources.releves.index', $source)
->with('success', 'Relevé supprimé.');
}
// Normalise les données POST en structure JSONB propre
private function buildData(array $raw, Source $source): array
{
$data = [];
foreach ($source->sourceType->fields as $field) {
$value = $raw[$field->name] ?? null;
$data[$field->name] = match ($field->type) {
FieldType::Boolean => (bool) ($value ?? false),
FieldType::Number => $value !== null && $value !== '' ? (float) $value : null,
FieldType::Date => [
'valeur' => $value['valeur'] ?? null,
'calendrier' => $value['calendrier'] ?? 'gregorien',
],
default => $value === '' ? null : $value,
};
}
return $data;
}
}
+76 -5
View File
@@ -6,26 +6,29 @@ use App\Enums\SourceStatus;
use App\Http\Requests\StoreSourceRequest;
use App\Http\Requests\UpdateSourceRequest;
use App\Models\Depot;
use App\Models\Lieu;
use App\Models\Source;
use App\Models\SourceType;
use App\Models\User;
use App\Notifications\SourceAValiderNotification;
use App\Notifications\SourceRejeteeNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class SourceController extends Controller
{
public function index(): View
public function index(Request $request): View
{
$this->authorize('viewAny', Source::class);
$user = auth()->user();
$query = Source::with(['sourceType', 'depot'])
$query = Source::with(['sourceType', 'depot', 'lieu'])
->withCount('releves');
if (! $user->isSectionManager()) {
// Membre : sources terminées + sources assignées
$assignedIds = $user->sourcesAssignees()->pluck('sources.id');
$query->where(function ($q) use ($assignedIds) {
$q->where('status', SourceStatus::Termine)
@@ -33,9 +36,56 @@ class SourceController extends Controller
});
}
$sources = $query->orderBy('nom')->paginate(25);
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
return view('sources.index', compact('sources'));
if ($request->filled('source_type_id')) {
$query->where('source_type_id', $request->integer('source_type_id'));
}
if ($request->filled('lieu_id')) {
$lieuIds = $this->getLieuDescendantIds($request->integer('lieu_id'));
$query->whereIn('lieu_id', $lieuIds);
}
if ($request->filled('annee_debut')) {
$annee = $request->integer('annee_debut');
$query->where(function ($q) use ($annee) {
$q->whereNull('annee_fin')->orWhere('annee_fin', '>=', $annee);
});
}
if ($request->filled('annee_fin')) {
$annee = $request->integer('annee_fin');
$query->where(function ($q) use ($annee) {
$q->whereNull('annee_debut')->orWhere('annee_debut', '<=', $annee);
});
}
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
$lieuSelectionne = $request->filled('lieu_id')
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
: null;
$sources = $query->orderBy('nom')->paginate(25)->withQueryString();
return view('sources.index', compact('sources', 'sourceTypes', 'lieuSelectionne'));
}
private function getLieuDescendantIds(int $lieuId): array
{
$rows = DB::select("
WITH RECURSIVE descendants AS (
SELECT id FROM lieux WHERE id = ?
UNION ALL
SELECT l.id FROM lieux l
INNER JOIN descendants d ON l.lieu_parent_id = d.id
)
SELECT id FROM descendants
", [$lieuId]);
return collect($rows)->pluck('id')->toArray();
}
public function create(): View
@@ -71,6 +121,7 @@ class SourceController extends Controller
{
$this->authorize('update', $source);
$source->loadMissing('lieu');
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
$depots = Depot::orderBy('nom')->get(['id', 'nom']);
@@ -136,8 +187,28 @@ class SourceController extends Controller
return back()->with('error', 'Transition non autorisée.');
}
$previousStatus = $source->status;
$source->update(['status' => $newStatus]);
$user = auth()->user();
if ($newStatus === SourceStatus::AValider) {
// Notifier admins + responsables de section
$source->load('sourceType', 'depot');
User::whereIn('role', ['admin', 'section_manager'])
->where('id', '!=', $user->id)
->get()
->each(fn ($u) => $u->notify(new SourceAValiderNotification($source, $user)));
}
if ($newStatus === SourceStatus::EnCours && $previousStatus === SourceStatus::AValider) {
// Rejet : notifier les membres assignés
$source->membres()
->where('users.id', '!=', $user->id)
->get()
->each(fn ($m) => $m->notify(new SourceRejeteeNotification($source, $user)));
}
return back()->with('success', 'Statut mis à jour : ' . $newStatus->label());
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreLieuTypeRequest extends FormRequest
{
public function authorize(): bool { return $this->user()->isAdmin(); }
public function rules(): array
{
$ignore = $this->route('lieuType')?->id;
return [
'nom' => ['required', 'string', 'max:100', Rule::unique('lieu_types', 'nom')->ignore($ignore)],
'ordre' => ['required', 'integer', 'min:0', 'max:999'],
];
}
}
+1
View File
@@ -15,6 +15,7 @@ class StoreLieuRequest extends FormRequest
{
return [
'nom' => ['required', 'string', 'max:255'],
'lieu_type_id' => ['required', 'integer', 'exists:lieu_types,id'],
'code' => ['nullable', 'string', 'max:20'],
'lieu_parent_id'=> ['nullable', 'integer', 'exists:lieux,id'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests;
use App\Enums\CalendarType;
use App\Enums\FieldType;
use App\Models\Source;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
class StoreReleveRequest extends FormRequest
{
public function authorize(): bool
{
$source = $this->route('source');
return $this->user()->can('create', [app(\App\Models\Releve::class), $source]);
}
public function rules(): array
{
/** @var Source $source */
$source = $this->route('source');
$source->loadMissing('sourceType.fields');
$rules = [];
foreach ($source->sourceType->fields as $field) {
$base = "data.{$field->name}";
switch ($field->type) {
case FieldType::Date:
$rules["{$base}.valeur"] = [$field->required ? 'required' : 'nullable', 'string', 'max:50'];
$rules["{$base}.calendrier"] = ['required', new Enum(CalendarType::class)];
break;
case FieldType::Boolean:
$rules[$base] = ['nullable', 'boolean'];
break;
case FieldType::Number:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'numeric'];
break;
case FieldType::Select:
$options = $field->options ?? [];
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)];
break;
default: // text, textarea
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
}
}
return $rules;
}
}
+3
View File
@@ -18,6 +18,9 @@ class StoreSourceRequest extends FormRequest
'description' => ['nullable', 'string'],
'source_type_id' => ['required', 'integer', 'exists:source_types,id'],
'depot_id' => ['nullable', 'integer', 'exists:depots,id'],
'lieu_id' => ['nullable', 'integer', 'exists:lieux,id'],
'annee_debut' => ['nullable', 'integer', 'min:1000', 'max:2100'],
'annee_fin' => ['nullable', 'integer', 'min:1000', 'max:2100', 'gte:annee_debut'],
'cote' => ['nullable', 'string', 'max:255'],
'auteur' => ['nullable', 'string', 'max:255'],
];
+1
View File
@@ -17,6 +17,7 @@ class UpdateLieuRequest extends FormRequest
return [
'nom' => ['required', 'string', 'max:255'],
'lieu_type_id' => ['required', 'integer', 'exists:lieu_types,id'],
'code' => ['nullable', 'string', 'max:20'],
// Interdit de se choisir soi-même ou un descendant comme parent
'lieu_parent_id'=> ['nullable', 'integer', 'exists:lieux,id', "not_in:{$lieuId}"],
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests;
use App\Enums\CalendarType;
use App\Enums\FieldType;
use App\Models\Source;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
class UpdateReleveRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('releve'));
}
public function rules(): array
{
/** @var \App\Models\Releve $releve */
$releve = $this->route('releve');
$releve->source->loadMissing('sourceType.fields');
$rules = [];
foreach ($releve->source->sourceType->fields as $field) {
$base = "data.{$field->name}";
switch ($field->type) {
case FieldType::Date:
$rules["{$base}.valeur"] = [$field->required ? 'required' : 'nullable', 'string', 'max:50'];
$rules["{$base}.calendrier"] = ['required', new Enum(CalendarType::class)];
break;
case FieldType::Boolean:
$rules[$base] = ['nullable', 'boolean'];
break;
case FieldType::Number:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'numeric'];
break;
case FieldType::Select:
$options = $field->options ?? [];
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)];
break;
default:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
}
}
return $rules;
}
}
@@ -18,6 +18,9 @@ class UpdateSourceRequest extends FormRequest
'description' => ['nullable', 'string'],
'source_type_id' => ['required', 'integer', 'exists:source_types,id'],
'depot_id' => ['nullable', 'integer', 'exists:depots,id'],
'lieu_id' => ['nullable', 'integer', 'exists:lieux,id'],
'annee_debut' => ['nullable', 'integer', 'min:1000', 'max:2100'],
'annee_fin' => ['nullable', 'integer', 'min:1000', 'max:2100', 'gte:annee_debut'],
'cote' => ['nullable', 'string', 'max:255'],
'auteur' => ['nullable', 'string', 'max:255'],
];
+6 -1
View File
@@ -10,7 +10,12 @@ class Lieu extends Model
{
protected $table = 'lieux';
protected $fillable = ['nom', 'code', 'lieu_parent_id', 'nom_long', 'latitude', 'longitude', 'note'];
protected $fillable = ['nom', 'code', 'lieu_type_id', 'lieu_parent_id', 'nom_long', 'latitude', 'longitude', 'note'];
public function lieuType(): BelongsTo
{
return $this->belongsTo(LieuType::class);
}
public function parent(): BelongsTo
{
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LieuType extends Model
{
protected $fillable = ['nom', 'ordre'];
public function lieux(): HasMany
{
return $this->hasMany(Lieu::class);
}
}
+6 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Source extends Model
{
protected $fillable = ['nom', 'description', 'source_type_id', 'depot_id', 'cote', 'auteur', 'status'];
protected $fillable = ['nom', 'description', 'source_type_id', 'depot_id', 'lieu_id', 'annee_debut', 'annee_fin', 'cote', 'auteur', 'status'];
protected $casts = [
'status' => SourceStatus::class,
@@ -26,6 +26,11 @@ class Source extends Model
return $this->belongsTo(Depot::class);
}
public function lieu(): BelongsTo
{
return $this->belongsTo(Lieu::class);
}
public function membres(): BelongsToMany
{
return $this->belongsToMany(User::class, 'source_user');
@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Source;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class SourceAValiderNotification extends Notification
{
use Queueable;
public function __construct(
public readonly Source $source,
public readonly User $soumispar,
) {}
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("Source à valider : {$this->source->nom}")
->greeting("Bonjour {$notifiable->name},")
->line("{$this->soumispar->name} a soumis la source **{$this->source->nom}** pour validation.")
->line("Type : {$this->source->sourceType->nom}")
->when($this->source->depot, fn ($m) => $m->line("Dépôt : {$this->source->depot->nom}"))
->action('Accéder à la source', route('sources.show', $this->source))
->line('Vous pouvez valider ou renvoyer cette source en cours de saisie.');
}
public function toDatabase(object $notifiable): array
{
return [
'source_id' => $this->source->id,
'source_nom' => $this->source->nom,
'soumis_par' => $this->soumispar->name,
'url' => route('sources.show', $this->source),
];
}
public function toArray(object $notifiable): array
{
return $this->toDatabase($notifiable);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Notifications;
use App\Models\Source;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class SourceRejeteeNotification extends Notification
{
use Queueable;
public function __construct(
public readonly Source $source,
public readonly User $rejetePar,
) {}
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("Relevés à corriger : {$this->source->nom}")
->greeting("Bonjour {$notifiable->name},")
->line("{$this->rejetePar->name} a renvoyé la source **{$this->source->nom}** en cours de saisie.")
->line("Des corrections sont nécessaires avant une nouvelle soumission.")
->action('Accéder à la source', route('sources.show', $this->source));
}
public function toDatabase(object $notifiable): array
{
return [
'source_id' => $this->source->id,
'source_nom' => $this->source->nom,
'rejete_par' => $this->rejetePar->name,
'url' => route('sources.show', $this->source),
'type' => 'rejet',
];
}
public function toArray(object $notifiable): array
{
return $this->toDatabase($notifiable);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Policies;
use App\Enums\SourceStatus;
use App\Models\Releve;
use App\Models\Source;
use App\Models\User;
class RelevePolicy
{
public function viewAny(User $user, Source $source): bool
{
return $source->isVisibleBy($user);
}
public function view(User $user, Releve $releve): bool
{
return $releve->source->isVisibleBy($user);
}
public function create(User $user, Source $source): bool
{
if ($source->status === SourceStatus::Termine) {
return false;
}
if ($user->isAdmin() || $user->isSectionManager()) {
return true;
}
return $source->membres()->where('user_id', $user->id)->exists();
}
public function update(User $user, Releve $releve): bool
{
return $this->create($user, $releve->source);
}
public function delete(User $user, Releve $releve): bool
{
return $user->isAdmin() || $user->isSectionManager();
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace App\Services;
use DateTime;
class DateConversionService
{
// Dates de début du 1er Vendémiaire pour chaque An républicain
private const YEAR_STARTS = [
1 => '1792-09-22',
2 => '1793-09-22',
3 => '1794-09-23',
4 => '1795-09-23',
5 => '1796-09-22',
6 => '1797-09-22',
7 => '1798-09-22',
8 => '1799-09-23',
9 => '1800-09-23',
10 => '1801-09-23',
11 => '1802-09-23',
12 => '1803-09-23',
13 => '1804-09-23',
14 => '1805-09-23',
];
private const MONTHS = [
'vendémiaire' => 1, 'vendemiaire' => 1,
'brumaire' => 2,
'frimaire' => 3,
'nivôse' => 4, 'nivose' => 4,
'pluviôse' => 5, 'pluviose' => 5,
'ventôse' => 6, 'ventose' => 6,
'germinal' => 7,
'floréal' => 8, 'floreal' => 8,
'prairial' => 9,
'messidor' => 10,
'thermidor' => 11,
'fructidor' => 12,
];
private const ROMAN = [
'XIV' => 14, 'XIII' => 13, 'XII' => 12, 'XI' => 11, 'X' => 10,
'IX' => 9, 'VIII' => 8, 'VII' => 7, 'VI' => 6, 'V' => 5,
'IV' => 4, 'III' => 3, 'II' => 2, 'I' => 1,
];
private const GEDCOM_MONTHS = [
1 => 'JAN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR',
5 => 'MAY', 6 => 'JUN', 7 => 'JUL', 8 => 'AUG',
9 => 'SEP', 10 => 'OCT', 11 => 'NOV', 12 => 'DEC',
];
/**
* Convertit un champ date JSONB { valeur, calendrier } en chaîne GEDCOM.
* Retourne null si la conversion échoue.
*/
public function toGedcomDate(?array $dateField): ?string
{
if (empty($dateField['valeur'])) {
return null;
}
$valeur = trim($dateField['valeur']);
$calendrier = $dateField['calendrier'] ?? 'gregorien';
return match ($calendrier) {
'gregorien' => $this->gregorianToGedcom($valeur),
'julien' => $this->julianToGedcom($valeur),
'republicain' => $this->republicanToGedcom($valeur),
default => null,
};
}
/** YYYY-MM-DD → "D MON YYYY" */
public function gregorianToGedcom(string $date): ?string
{
$d = DateTime::createFromFormat('Y-m-d', $date);
if (! $d) {
return null;
}
$day = (int) $d->format('j');
$month = self::GEDCOM_MONTHS[(int) $d->format('n')];
$year = $d->format('Y');
return "{$day} {$month} {$year}";
}
/** Julien : même format YYYY-MM-DD, marqué @#DJULIAN@ */
private function julianToGedcom(string $date): ?string
{
$g = $this->gregorianToGedcom($date);
return $g ? "@#DJULIAN@ {$g}" : null;
}
/**
* "15 Vendémiaire An III" date grégorienne GEDCOM.
* Retourne la date avec préfixe @#DFRENCH R@ (standard GEDCOM pour calendrier républicain).
*/
private function republicanToGedcom(string $date): ?string
{
$gregory = $this->republicanToGregorian($date);
if ($gregory) {
return $this->gregorianToGedcom($gregory);
}
// Fallback : on conserve la date telle quelle dans un format lisible
return "({$date})";
}
/**
* Convertit "15 Vendémiaire An III" "1794-10-06".
*/
public function republicanToGregorian(string $input): ?string
{
// Normaliser l'entrée
$input = trim($input);
// Pattern : "DD NomDuMois An N" ou "DD NomDuMois An XIV"
$pattern = '/^(\d{1,2})\s+([\wéèêôûî]+)\s+[Aa]n\s+([IVXLCDM\d]+)$/iu';
if (! preg_match($pattern, $input, $m)) {
return null;
}
$day = (int) $m[1];
$monthStr = mb_strtolower(trim($m[2]));
$yearStr = strtoupper(trim($m[3]));
// Résoudre le mois
$monthNum = self::MONTHS[$monthStr] ?? null;
if (! $monthNum || $day < 1 || $day > 30) {
return null;
}
// Résoudre l'année (chiffres arabes ou romains)
$yearNum = is_numeric($yearStr)
? (int) $yearStr
: (self::ROMAN[$yearStr] ?? null);
if (! $yearNum || ! isset(self::YEAR_STARTS[$yearNum])) {
return null;
}
// Calculer le nombre de jours depuis le 1er Vendémiaire
$daysOffset = ($monthNum - 1) * 30 + ($day - 1);
$start = new DateTime(self::YEAR_STARTS[$yearNum]);
$start->modify("+{$daysOffset} days");
return $start->format('Y-m-d');
}
}
+228
View File
@@ -0,0 +1,228 @@
<?php
namespace App\Services;
use App\Models\Releve;
use App\Models\Source;
use Illuminate\Support\Collection;
class GedcomExportService
{
// Mapping des noms de champs vers les tags GEDCOM
private const FIELD_EVENT_MAP = [
'naissance' => 'BIRT', 'birth' => 'BIRT',
'mariage' => 'MARR', 'marriage' => 'MARR',
'deces' => 'DEAT', 'décès' => 'DEAT', 'death' => 'DEAT',
];
public function __construct(
private readonly DateConversionService $dates,
) {}
public function exportSource(Source $source): string
{
$source->load('sourceType.fields', 'releves.createur');
return $this->buildGedcom($source->releves, $source->nom, $source->sourceType->nom);
}
public function exportReleves(Collection $releves, string $titre = 'Export'): string
{
return $this->buildGedcom($releves, $titre);
}
private function buildGedcom(Collection $releves, string $sourceName, string $sourceTypeName = ''): string
{
$lines = [];
$lines[] = $this->header($sourceName);
$sourceTag = '@S1@';
$famId = 0;
$indiId = 0;
// Détecter le type d'événement depuis le nom du type de source
$eventType = $this->detectEventType($sourceTypeName ?: $sourceName);
$individuals = [];
$families = [];
foreach ($releves as $releve) {
$data = $releve->data ?? [];
$indiId++;
$indiTag = "@I{$indiId}@";
$nom = $data['nom'] ?? '';
$prenom = $data['prenom'] ?? $data['prenom_epoux'] ?? $data['prenom_epouse'] ?? '';
// ── Individu principal ──────────────────────────────────────────
$indi = [];
$indi[] = "0 {$indiTag} INDI";
if ($nom || $prenom) {
$indi[] = "1 NAME {$prenom} /{$nom}/";
if ($prenom) $indi[] = "1 GIVN {$prenom}";
if ($nom) $indi[] = "1 SURN {$nom}";
}
// Événement principal
$dateField = $data['date_evenement'] ?? $data['date_naissance'] ?? $data['date_mariage'] ?? null;
$gedDate = is_array($dateField) ? $this->dates->toGedcomDate($dateField) : null;
$lieu = $data['lieu_naissance'] ?? $data['lieu_evenement'] ?? $data['lieu_mariage'] ?? null;
$indi[] = "1 {$eventType}";
if ($gedDate) $indi[] = "2 DATE {$gedDate}";
if ($lieu) $indi[] = "2 PLAC {$lieu}";
// Référence à la source
$indi[] = "1 SOUR {$sourceTag}";
if (isset($data['numero_acte'])) {
$indi[] = "2 PAGE Acte n°{$data['numero_acte']}";
}
// Notes (champs non mappés)
$noteFields = ['note', 'observation', 'remarque'];
foreach ($noteFields as $nf) {
if (!empty($data[$nf])) {
foreach ($this->wrapNote($data[$nf]) as $noteLine) {
$indi[] = $noteLine;
}
}
}
$individuals[] = implode("\n", $indi);
// ── Famille (père/mère ou époux/épouse) ─────────────────────────
$hasFather = !empty($data['nom_pere']) || !empty($data['prenom_pere']);
$hasMother = !empty($data['nom_mere']) || !empty($data['prenom_mere']);
$isMarriage = $eventType === 'MARR';
if ($hasFather || $hasMother || $isMarriage) {
$famId++;
$famTag = "@F{$famId}@";
$fam = [];
$fam[] = "0 {$famTag} FAM";
if ($isMarriage) {
// Pour un mariage, créer époux et épouse séparément
if (!empty($data['nom_epoux']) || !empty($data['prenom_epoux'])) {
$indiId++;
$epouxTag = "@I{$indiId}@";
$individuals[] = implode("\n", [
"0 {$epouxTag} INDI",
"1 NAME {$data['prenom_epoux']} /{$data['nom_epoux']}/",
"1 FAMS {$famTag}",
"1 SOUR {$sourceTag}",
]);
$fam[] = "1 HUSB {$epouxTag}";
}
if (!empty($data['nom_epouse']) || !empty($data['prenom_epouse'])) {
$indiId++;
$epouseTag = "@I{$indiId}@";
$individuals[] = implode("\n", [
"0 {$epouseTag} INDI",
"1 NAME {$data['prenom_epouse']} /{$data['nom_epouse']}/",
"1 FAMS {$famTag}",
"1 SOUR {$sourceTag}",
]);
$fam[] = "1 WIFE {$epouseTag}";
}
$fam[] = "1 MARR";
if ($gedDate) $fam[] = "2 DATE {$gedDate}";
if ($lieu) $fam[] = "2 PLAC {$lieu}";
// Lier l'individu principal comme enfant si besoin
} else {
// Naissance/décès : père et mère
if ($hasFather) {
$indiId++;
$pereTag = "@I{$indiId}@";
$individuals[] = implode("\n", [
"0 {$pereTag} INDI",
"1 NAME " . ($data['prenom_pere'] ?? '') . " /" . ($data['nom_pere'] ?? '') . "/",
"1 FAMS {$famTag}",
"1 SOUR {$sourceTag}",
]);
$fam[] = "1 HUSB {$pereTag}";
}
if ($hasMother) {
$indiId++;
$mereTag = "@I{$indiId}@";
$individuals[] = implode("\n", [
"0 {$mereTag} INDI",
"1 NAME " . ($data['prenom_mere'] ?? '') . " /" . ($data['nom_mere'] ?? '') . "/",
"1 FAMS {$famTag}",
"1 SOUR {$sourceTag}",
]);
$fam[] = "1 WIFE {$mereTag}";
}
$fam[] = "1 CHIL {$indiTag}";
// Lier l'enfant à sa famille
$indi[] = "1 FAMC {$famTag}";
}
$families[] = implode("\n", $fam);
}
}
foreach ($individuals as $indi) {
$lines[] = $indi;
}
foreach ($families as $fam) {
$lines[] = $fam;
}
// Enregistrement SOURCE
$lines[] = implode("\n", [
"0 {$sourceTag} SOUR",
"1 TITL {$sourceName}",
"1 AUTH MesRelevés",
]);
$lines[] = "0 TRLR";
return implode("\n", $lines) . "\n";
}
private function header(string $sourceName): string
{
$date = now()->format('d M Y');
$time = now()->format('H:i:s');
return implode("\n", [
'0 HEAD',
'1 SOUR MESRELEVES',
'2 NAME MesRelevés',
'2 VERS 1.0',
'1 DEST ANY',
"1 DATE {$date}",
"2 TIME {$time}",
'1 GEDC',
'2 VERS 5.5.1',
'2 FORM LINEAGE-LINKED',
'1 CHAR UTF-8',
"1 FILE {$sourceName}.ged",
'1 LANG French',
]);
}
private function detectEventType(string $name): string
{
$lower = mb_strtolower($name);
foreach (self::FIELD_EVENT_MAP as $keyword => $tag) {
if (str_contains($lower, $keyword)) {
return $tag;
}
}
return 'EVEN';
}
/** Découpe une note longue en lignes GEDCOM (max 248 chars par ligne) */
private function wrapNote(string $text): array
{
$lines = [];
$chunks = mb_str_split($text, 248);
foreach ($chunks as $i => $chunk) {
$lines[] = ($i === 0 ? '1 NOTE ' : '2 CONT ') . $chunk;
}
return $lines;
}
}