diff --git a/app/Http/Controllers/Admin/LieuTypeController.php b/app/Http/Controllers/Admin/LieuTypeController.php new file mode 100644 index 0000000..ba67892 --- /dev/null +++ b/app/Http/Controllers/Admin/LieuTypeController.php @@ -0,0 +1,49 @@ +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é.'); + } +} diff --git a/app/Http/Controllers/Admin/SectionController.php b/app/Http/Controllers/Admin/SectionController.php index 47023de..0215217 100644 --- a/app/Http/Controllers/Admin/SectionController.php +++ b/app/Http/Controllers/Admin/SectionController.php @@ -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 diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5..4a02245 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -4,5 +4,5 @@ namespace App\Http\Controllers; abstract class Controller { - // + use \Illuminate\Foundation\Auth\Access\AuthorizesRequests; } diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php new file mode 100644 index 0000000..b79ee15 --- /dev/null +++ b/app/Http/Controllers/ExportController.php @@ -0,0 +1,111 @@ +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); + } +} diff --git a/app/Http/Controllers/LieuController.php b/app/Http/Controllers/LieuController.php index e7bff87..d9fab90 100644 --- a/app/Http/Controllers/LieuController.php +++ b/app/Http/Controllers/LieuController.php @@ -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); } } diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..0964fd0 --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,37 @@ +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.'); + } +} diff --git a/app/Http/Controllers/RechercheController.php b/app/Http/Controllers/RechercheController.php new file mode 100644 index 0000000..c3fd2b8 --- /dev/null +++ b/app/Http/Controllers/RechercheController.php @@ -0,0 +1,116 @@ +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(); + } +} diff --git a/app/Http/Controllers/ReleveController.php b/app/Http/Controllers/ReleveController.php new file mode 100644 index 0000000..271ebfb --- /dev/null +++ b/app/Http/Controllers/ReleveController.php @@ -0,0 +1,115 @@ +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; + } +} diff --git a/app/Http/Controllers/SourceController.php b/app/Http/Controllers/SourceController.php index 939c30a..ffe36f2 100644 --- a/app/Http/Controllers/SourceController.php +++ b/app/Http/Controllers/SourceController.php @@ -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()); } } diff --git a/app/Http/Requests/Admin/StoreLieuTypeRequest.php b/app/Http/Requests/Admin/StoreLieuTypeRequest.php new file mode 100644 index 0000000..51a465e --- /dev/null +++ b/app/Http/Requests/Admin/StoreLieuTypeRequest.php @@ -0,0 +1,21 @@ +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'], + ]; + } +} diff --git a/app/Http/Requests/StoreLieuRequest.php b/app/Http/Requests/StoreLieuRequest.php index a72fba2..b021c7d 100644 --- a/app/Http/Requests/StoreLieuRequest.php +++ b/app/Http/Requests/StoreLieuRequest.php @@ -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'], diff --git a/app/Http/Requests/StoreReleveRequest.php b/app/Http/Requests/StoreReleveRequest.php new file mode 100644 index 0000000..5a9dae4 --- /dev/null +++ b/app/Http/Requests/StoreReleveRequest.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/app/Http/Requests/StoreSourceRequest.php b/app/Http/Requests/StoreSourceRequest.php index fbf370e..0ee387f 100644 --- a/app/Http/Requests/StoreSourceRequest.php +++ b/app/Http/Requests/StoreSourceRequest.php @@ -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'], ]; diff --git a/app/Http/Requests/UpdateLieuRequest.php b/app/Http/Requests/UpdateLieuRequest.php index bd2fe17..5f4d9a9 100644 --- a/app/Http/Requests/UpdateLieuRequest.php +++ b/app/Http/Requests/UpdateLieuRequest.php @@ -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}"], diff --git a/app/Http/Requests/UpdateReleveRequest.php b/app/Http/Requests/UpdateReleveRequest.php new file mode 100644 index 0000000..b33813d --- /dev/null +++ b/app/Http/Requests/UpdateReleveRequest.php @@ -0,0 +1,51 @@ +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; + } +} diff --git a/app/Http/Requests/UpdateSourceRequest.php b/app/Http/Requests/UpdateSourceRequest.php index 035ebb0..09650e9 100644 --- a/app/Http/Requests/UpdateSourceRequest.php +++ b/app/Http/Requests/UpdateSourceRequest.php @@ -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'], ]; diff --git a/app/Models/Lieu.php b/app/Models/Lieu.php index 1cf6fc3..e8bca2a 100644 --- a/app/Models/Lieu.php +++ b/app/Models/Lieu.php @@ -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 { diff --git a/app/Models/LieuType.php b/app/Models/LieuType.php new file mode 100644 index 0000000..c7d583f --- /dev/null +++ b/app/Models/LieuType.php @@ -0,0 +1,16 @@ +hasMany(Lieu::class); + } +} diff --git a/app/Models/Source.php b/app/Models/Source.php index b653be7..fdf6f52 100644 --- a/app/Models/Source.php +++ b/app/Models/Source.php @@ -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'); diff --git a/app/Notifications/SourceAValiderNotification.php b/app/Notifications/SourceAValiderNotification.php new file mode 100644 index 0000000..e94457a --- /dev/null +++ b/app/Notifications/SourceAValiderNotification.php @@ -0,0 +1,51 @@ +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); + } +} diff --git a/app/Notifications/SourceRejeteeNotification.php b/app/Notifications/SourceRejeteeNotification.php new file mode 100644 index 0000000..9cd698b --- /dev/null +++ b/app/Notifications/SourceRejeteeNotification.php @@ -0,0 +1,50 @@ +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); + } +} diff --git a/app/Policies/RelevePolicy.php b/app/Policies/RelevePolicy.php new file mode 100644 index 0000000..1862e08 --- /dev/null +++ b/app/Policies/RelevePolicy.php @@ -0,0 +1,42 @@ +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(); + } +} diff --git a/app/Services/DateConversionService.php b/app/Services/DateConversionService.php new file mode 100644 index 0000000..25edf8e --- /dev/null +++ b/app/Services/DateConversionService.php @@ -0,0 +1,152 @@ + '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'); + } +} diff --git a/app/Services/GedcomExportService.php b/app/Services/GedcomExportService.php new file mode 100644 index 0000000..eefb23d --- /dev/null +++ b/app/Services/GedcomExportService.php @@ -0,0 +1,228 @@ + '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; + } +} diff --git a/database/migrations/2024_01_01_090000_create_lieu_types_table.php b/database/migrations/2024_01_01_090000_create_lieu_types_table.php new file mode 100644 index 0000000..9d23ddb --- /dev/null +++ b/database/migrations/2024_01_01_090000_create_lieu_types_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('nom')->unique(); + $table->unsignedSmallInteger('ordre')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('lieu_types'); + } +}; diff --git a/database/migrations/2024_01_01_100000_create_lieux_table.php b/database/migrations/2024_01_01_100000_create_lieux_table.php index 44413c1..bc6cb97 100644 --- a/database/migrations/2024_01_01_100000_create_lieux_table.php +++ b/database/migrations/2024_01_01_100000_create_lieux_table.php @@ -10,10 +10,11 @@ return new class extends Migration { Schema::create('lieux', function (Blueprint $table) { $table->id(); + $table->foreignId('lieu_type_id')->nullable()->constrained('lieu_types')->nullOnDelete(); $table->string('nom'); $table->string('code')->nullable(); $table->foreignId('lieu_parent_id')->nullable()->constrained('lieux')->nullOnDelete(); - $table->string('nom_long')->nullable(); // calculé : "Bordeaux, Gironde, France" + $table->string('nom_long')->nullable(); $table->decimal('latitude', 10, 7)->nullable(); $table->decimal('longitude', 10, 7)->nullable(); $table->text('note')->nullable(); diff --git a/database/migrations/2026_06_04_144856_create_notifications_table.php b/database/migrations/2026_06_04_144856_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/database/migrations/2026_06_04_144856_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_06_04_170000_add_lieu_and_years_to_sources_table.php b/database/migrations/2026_06_04_170000_add_lieu_and_years_to_sources_table.php new file mode 100644 index 0000000..33a0efb --- /dev/null +++ b/database/migrations/2026_06_04_170000_add_lieu_and_years_to_sources_table.php @@ -0,0 +1,25 @@ +foreignId('lieu_id')->nullable()->after('depot_id')->constrained('lieux')->nullOnDelete(); + $table->unsignedSmallInteger('annee_debut')->nullable()->after('lieu_id'); + $table->unsignedSmallInteger('annee_fin')->nullable()->after('annee_debut'); + }); + } + + public function down(): void + { + Schema::table('sources', function (Blueprint $table) { + $table->dropForeign(['lieu_id']); + $table->dropColumn(['lieu_id', 'annee_debut', 'annee_fin']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..52e6ffe 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,24 +2,164 @@ namespace Database\Seeders; +use App\Enums\FieldType; +use App\Enums\SourceStatus; +use App\Enums\UserRole; +use App\Models\Depot; +use App\Models\Lieu; +use App\Models\LieuType; +use App\Models\Releve; +use App\Models\Section; +use App\Models\Source; +use App\Models\SourceType; +use App\Models\SourceTypeField; use App\Models\User; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder { - use WithoutModelEvents; - - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); + // ── Utilisateurs ──────────────────────────────────────────────────── + $admin = User::create([ + 'name' => 'Administrateur', + 'email' => 'admin@mesreleves.local', + 'password' => Hash::make('password'), + 'role' => UserRole::Admin, + ]); - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $responsable = User::create([ + 'name' => 'Responsable Bordeaux', + 'email' => 'responsable@mesreleves.local', + 'password' => Hash::make('password'), + 'role' => UserRole::SectionManager, + ]); + + $membre = User::create([ + 'name' => 'Marie Durand', + 'email' => 'membre@mesreleves.local', + 'password' => Hash::make('password'), + 'role' => UserRole::Member, + ]); + + // ── Types de lieux ────────────────────────────────────────────────── + $typePays = LieuType::create(['nom' => 'Pays', 'ordre' => 0]); + $typeRegion = LieuType::create(['nom' => 'Région', 'ordre' => 10]); + $typeDept = LieuType::create(['nom' => 'Département', 'ordre' => 20]); + $typeVille = LieuType::create(['nom' => 'Ville', 'ordre' => 30]); + $typeCommune = LieuType::create(['nom' => 'Commune', 'ordre' => 35]); + $typeParoisse = LieuType::create(['nom' => 'Paroisse', 'ordre' => 40]); + $typeLieuDit = LieuType::create(['nom' => 'Lieu-dit', 'ordre' => 50]); + + // ── Lieux ─────────────────────────────────────────────────────────── + $france = Lieu::create(['nom' => 'France', 'lieu_type_id' => $typePays->id]); + $gironde = Lieu::create(['nom' => 'Gironde', 'code' => '33', 'lieu_type_id' => $typeDept->id, 'lieu_parent_id' => $france->id]); + $bordeaux = Lieu::create(['nom' => 'Bordeaux', 'code' => '33063', 'lieu_type_id' => $typeVille->id, 'lieu_parent_id' => $gironde->id, + 'latitude' => 44.8378, 'longitude' => -0.5792]); + $begles = Lieu::create(['nom' => 'Bègles', 'code' => '33032', 'lieu_type_id' => $typeCommune->id, 'lieu_parent_id' => $gironde->id]); + + // ── Section ───────────────────────────────────────────────────────── + $section = Section::create([ + 'nom' => 'Section Gironde', + 'lieu_id' => $bordeaux->id, + 'email_contact' => 'gironde@cgl.fr', + ]); + $section->membres()->attach($responsable->id, ['role_in_section' => 'section_manager']); + $section->membres()->attach($membre->id, ['role_in_section' => 'member']); + + // ── Dépôt ─────────────────────────────────────────────────────────── + $depot = Depot::create([ + 'nom' => 'Archives Départementales de la Gironde', + 'adresse_postale' => '72 cours Balguerie-Stuttenberg, 33000 Bordeaux', + 'url' => 'https://archives.gironde.fr', + ]); + + // ── Type de source : État civil Naissance ──────────────────────────── + $stNaissance = SourceType::create([ + 'nom' => 'État civil — Naissance', + 'description' => 'Actes de naissance (format post-révolutionnaire)', + ]); + + $champsNaissance = [ + ['name' => 'nom', 'label' => 'Nom', 'type' => FieldType::Text, 'required' => true, 'order' => 0], + ['name' => 'prenom', 'label' => 'Prénom(s)', 'type' => FieldType::Text, 'required' => true, 'order' => 1], + ['name' => 'date_evenement', 'label' => 'Date de naissance', 'type' => FieldType::Date, 'required' => true, 'order' => 2], + ['name' => 'lieu_naissance', 'label' => 'Lieu de naissance', 'type' => FieldType::Text, 'required' => false, 'order' => 3], + ['name' => 'numero_acte', 'label' => 'N° acte', 'type' => FieldType::Number, 'required' => false, 'order' => 4], + ['name' => 'nom_pere', 'label' => 'Nom du père', 'type' => FieldType::Text, 'required' => false, 'order' => 5], + ['name' => 'prenom_pere', 'label' => 'Prénom du père', 'type' => FieldType::Text, 'required' => false, 'order' => 6], + ['name' => 'nom_mere', 'label' => 'Nom de la mère', 'type' => FieldType::Text, 'required' => false, 'order' => 7], + ['name' => 'prenom_mere', 'label' => 'Prénom de la mère', 'type' => FieldType::Text, 'required' => false, 'order' => 8], + ['name' => 'note', 'label' => 'Note', 'type' => FieldType::Textarea, 'required' => false, 'order' => 9], + ]; + + foreach ($champsNaissance as $champ) { + SourceTypeField::create(['source_type_id' => $stNaissance->id, ...$champ]); + } + + // ── Type de source : Mariage ───────────────────────────────────────── + $stMariage = SourceType::create(['nom' => 'État civil — Mariage']); + + $champsMariage = [ + ['name' => 'nom_epoux', 'label' => 'Nom de l\'époux', 'type' => FieldType::Text, 'required' => true, 'order' => 0], + ['name' => 'prenom_epoux', 'label' => 'Prénom de l\'époux','type' => FieldType::Text, 'required' => true, 'order' => 1], + ['name' => 'nom_epouse', 'label' => 'Nom de l\'épouse', 'type' => FieldType::Text, 'required' => true, 'order' => 2], + ['name' => 'prenom_epouse', 'label' => 'Prénom de l\'épouse','type'=> FieldType::Text, 'required' => true, 'order' => 3], + ['name' => 'date_mariage', 'label' => 'Date du mariage', 'type' => FieldType::Date, 'required' => true, 'order' => 4], + ['name' => 'numero_acte', 'label' => 'N° acte', 'type' => FieldType::Number,'required'=> false, 'order' => 5], + ['name' => 'note', 'label' => 'Note', 'type' => FieldType::Textarea,'required'=> false,'order'=> 6], + ]; + + foreach ($champsMariage as $champ) { + SourceTypeField::create(['source_type_id' => $stMariage->id, ...$champ]); + } + + // ── Source : naissances Bordeaux (en cours, membre assigné) ────────── + $sourceNaissances = Source::create([ + 'nom' => 'Naissances Bordeaux 1820–1830', + 'source_type_id' => $stNaissance->id, + 'depot_id' => $depot->id, + 'cote' => '4E 756', + 'auteur' => 'Marie Durand', + 'status' => SourceStatus::EnCours, + ]); + $sourceNaissances->membres()->attach($membre->id); + + // ── Relevés de la source naissances ────────────────────────────────── + $releves = [ + ['nom' => 'DUPONT', 'prenom' => 'Jean Marie', 'date_evenement' => ['valeur' => '1821-04-03', 'calendrier' => 'gregorien'], 'lieu_naissance' => 'Bordeaux', 'numero_acte' => 12, 'nom_pere' => 'DUPONT', 'prenom_pere' => 'Pierre', 'nom_mere' => 'MARTIN', 'prenom_mere' => 'Jeanne', 'note' => null], + ['nom' => 'MARTIN', 'prenom' => 'Marie Anne', 'date_evenement' => ['valeur' => '1822-07-15', 'calendrier' => 'gregorien'], 'lieu_naissance' => 'Bordeaux', 'numero_acte' => 34, 'nom_pere' => 'MARTIN', 'prenom_pere' => 'Louis', 'nom_mere' => 'BERNARD', 'prenom_mere' => 'Claire', 'note' => null], + ['nom' => 'BERNARD', 'prenom' => 'Pierre Louis', 'date_evenement' => ['valeur' => '1823-01-28', 'calendrier' => 'gregorien'], 'lieu_naissance' => 'Bègles', 'numero_acte' => 8, 'nom_pere' => 'BERNARD', 'prenom_pere' => 'Jacques', 'nom_mere' => 'LEBRUN', 'prenom_mere' => 'Marie', 'note' => 'Né prématurément'], + ['nom' => 'LEBRUN', 'prenom' => 'Céleste', 'date_evenement' => ['valeur' => '6 Vendémiaire An XI', 'calendrier' => 'republicain'], 'lieu_naissance' => 'Bordeaux', 'numero_acte' => 51, 'nom_pere' => null, 'prenom_pere' => null, 'nom_mere' => 'LEBRUN', 'prenom_mere' => 'Angélique', 'note' => 'Père inconnu'], + ['nom' => 'ROUX', 'prenom' => 'Henri Gustave', 'date_evenement' => ['valeur' => '1825-11-02', 'calendrier' => 'gregorien'], 'lieu_naissance' => 'Bordeaux', 'numero_acte' => 89, 'nom_pere' => 'ROUX', 'prenom_pere' => 'Étienne', 'nom_mere' => 'PETIT', 'prenom_mere' => 'Louise', 'note' => null], + ]; + + foreach ($releves as $data) { + Releve::create([ + 'source_id' => $sourceNaissances->id, + 'created_by' => $membre->id, + 'updated_by' => $membre->id, + 'data' => $data, + ]); + } + + // ── Source : mariages (à valider) ──────────────────────────────────── + Source::create([ + 'nom' => 'Mariages Bordeaux 1815–1820', + 'source_type_id' => $stMariage->id, + 'depot_id' => $depot->id, + 'cote' => '4E 750', + 'status' => SourceStatus::AValider, + ]); + + // ── Source : à faire ───────────────────────────────────────────────── + Source::create([ + 'nom' => 'Naissances Bègles 1830–1840', + 'source_type_id' => $stNaissance->id, + 'depot_id' => $depot->id, + 'cote' => '4E 820', + 'status' => SourceStatus::AFaire, ]); } } diff --git a/resources/views/admin/lieu-types/_form.blade.php b/resources/views/admin/lieu-types/_form.blade.php new file mode 100644 index 0000000..e2ab685 --- /dev/null +++ b/resources/views/admin/lieu-types/_form.blade.php @@ -0,0 +1,17 @@ +
+
+ + + @error('nom')

{{ $message }}

@enderror +
+ +
+ + +

Les valeurs les plus basses apparaissent en premier (ex : Pays=0, Région=10, Département=20, Ville=30…)

+
+
diff --git a/resources/views/admin/lieu-types/create.blade.php b/resources/views/admin/lieu-types/create.blade.php new file mode 100644 index 0000000..e8fb48e --- /dev/null +++ b/resources/views/admin/lieu-types/create.blade.php @@ -0,0 +1,15 @@ + +

Nouveau type de lieu

+
+
+
+ @csrf + @include('admin.lieu-types._form', ['lieuType' => null]) +
+ + Annuler +
+
+
+
+
diff --git a/resources/views/admin/lieu-types/edit.blade.php b/resources/views/admin/lieu-types/edit.blade.php new file mode 100644 index 0000000..a46835a --- /dev/null +++ b/resources/views/admin/lieu-types/edit.blade.php @@ -0,0 +1,15 @@ + +

Modifier : {{ $lieuType->nom }}

+
+
+
+ @csrf @method('PUT') + @include('admin.lieu-types._form', ['lieuType' => $lieuType]) +
+ + Annuler +
+
+
+
+
diff --git a/resources/views/admin/lieu-types/index.blade.php b/resources/views/admin/lieu-types/index.blade.php new file mode 100644 index 0000000..d730385 --- /dev/null +++ b/resources/views/admin/lieu-types/index.blade.php @@ -0,0 +1,56 @@ + + +
+

Types de lieux

+ + Nouveau type +
+
+ +
+ @foreach(['success','error'] as $flash) + @if(session($flash)) +
+ {{ session($flash) }} +
+ @endif + @endforeach + +
+ + + + + + + + + + + @forelse($lieuTypes as $lt) + + + + + + + @empty + + + + @endforelse + +
OrdreNomLieux
{{ $lt->ordre }}{{ $lt->nom }}{{ $lt->lieux_count }} + Modifier +
+ @csrf @method('DELETE') + +
+
+ Aucun type de lieu défini. +
+
+
+
diff --git a/resources/views/admin/sections/_form.blade.php b/resources/views/admin/sections/_form.blade.php index 719e0b8..e337929 100644 --- a/resources/views/admin/sections/_form.blade.php +++ b/resources/views/admin/sections/_form.blade.php @@ -6,18 +6,14 @@ @error('nom')

{{ $message }}

@enderror -
- - -
+
diff --git a/resources/views/components/lieu-picker.blade.php b/resources/views/components/lieu-picker.blade.php new file mode 100644 index 0000000..5f4c156 --- /dev/null +++ b/resources/views/components/lieu-picker.blade.php @@ -0,0 +1,198 @@ +{{-- + Composant de sélection d'un lieu par recherche contextuelle. + + Paramètres : + $name : nom du champ hidden (ex: "lieu_id") + $label : libellé affiché au-dessus du champ + $value : id du lieu sélectionné (null si aucun) + $displayValue : texte affiché (nom_long du lieu sélectionné) + $required : bool — rend le champ obligatoire + $placeholder : texte quand rien n'est sélectionné +--}} +@props([ + 'name', + 'label' => 'Lieu', + 'value' => null, + 'displayValue' => '', + 'required' => false, + 'placeholder' => 'Rechercher un lieu…', +]) + +
+ + + {{-- Champ hidden pour la valeur soumise --}} + + + {{-- Affichage du lieu sélectionné + boutons --}} +
+ + + +
+ + @error($name) +

{{ $message }}

+ @enderror + + {{-- Modale de recherche --}} +
+ {{-- Fond semi-transparent --}} +
+ + {{-- Panneau --}} +
+ {{-- En-tête --}} +
+ + + + + +
+ + {{-- Résultats --}} +
+ {{-- Chargement --}} +
+ Recherche… +
+ + {{-- Aucun résultat --}} +
+ Aucun lieu trouvé pour « » +
+ + {{-- Invite initiale --}} +
+ Saisissez au moins une lettre pour rechercher +
+ + {{-- Liste --}} +
    + +
+
+ + @can('create', App\Models\Lieu::class) + {{-- Pied : créer un nouveau lieu --}} + + @endcan +
+
+
diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index c2d3a65..c93819e 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -1,30 +1,86 @@
+ @else + {{-- État initial --}} +
+ + + +

Saisissez un nom, prénom, lieu ou tout autre terme pour rechercher dans les relevés.

+
+ @endif + + + diff --git a/resources/views/releves/_field.blade.php b/resources/views/releves/_field.blade.php new file mode 100644 index 0000000..18dcd2f --- /dev/null +++ b/resources/views/releves/_field.blade.php @@ -0,0 +1,100 @@ +{{-- + Rendu d'un champ dynamique selon son FieldType. + Variables attendues : $field (SourceTypeField), $value (valeur courante ou null) +--}} +@php + use App\Enums\FieldType; + $name = "data[{$field->name}]"; + $inputId = "field_{$field->name}"; + $oldValue = old("data.{$field->name}", $value); +@endphp + +
+ + + @switch($field->type) + + @case(FieldType::Text) + required ? 'required' : '' }} + class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500 @error("data.{$field->name}") border-red-500 @enderror"> + @break + + @case(FieldType::Textarea) + + @break + + @case(FieldType::Number) + required ? 'required' : '' }} + class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500 @error("data.{$field->name}") border-red-500 @enderror"> + @break + + @case(FieldType::Boolean) + @php $checked = old("data.{$field->name}", $value) ? true : false; @endphp +
+ + + {{ $field->label }} +
+ @break + + @case(FieldType::Select) + + @break + + @case(FieldType::Date) + @php + $dateVal = is_array($oldValue) ? ($oldValue['valeur'] ?? '') : ''; + $dateCal = is_array($oldValue) ? ($oldValue['calendrier'] ?? 'gregorien') : old("data.{$field->name}.calendrier", 'gregorien'); + @endphp +
+ {{-- Sélecteur de calendrier --}} + + + {{-- Date grégorienne / julienne : input date HTML5 --}} + required ? 'required' : '' }} + class="flex-1 rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500"> + + {{-- Date républicaine : saisie texte libre (ex: "15 Vendémiaire An III") --}} + +
+ @error("data.{$field->name}.valeur") +

{{ $message }}

+ @enderror + @break + + @endswitch + + @error("data.{$field->name}") +

{{ $message }}

+ @enderror +
diff --git a/resources/views/releves/_form.blade.php b/resources/views/releves/_form.blade.php new file mode 100644 index 0000000..c34c545 --- /dev/null +++ b/resources/views/releves/_form.blade.php @@ -0,0 +1,15 @@ +{{-- $source->sourceType->fields doit être chargé --}} +{{-- $releve : null pour création, instance pour édition --}} +
+ @forelse($source->sourceType->fields as $field) + @php + $rawValue = $releve?->data[$field->name] ?? null; + @endphp + @include('releves._field', ['field' => $field, 'value' => $rawValue]) + @empty +

+ Ce type de source n'a aucun champ défini. + Configurer les champs → +

+ @endforelse +
diff --git a/resources/views/releves/create.blade.php b/resources/views/releves/create.blade.php new file mode 100644 index 0000000..734aca3 --- /dev/null +++ b/resources/views/releves/create.blade.php @@ -0,0 +1,28 @@ + + +
+

Nouveau relevé

+

+ Source : {{ $source->nom }} + · Type : {{ $source->sourceType->nom }} +

+
+
+ +
+
+
+ @csrf + @include('releves._form', ['releve' => null]) +
+ + Annuler +
+
+
+
+
diff --git a/resources/views/releves/edit.blade.php b/resources/views/releves/edit.blade.php new file mode 100644 index 0000000..cd4b001 --- /dev/null +++ b/resources/views/releves/edit.blade.php @@ -0,0 +1,27 @@ + + +
+

Modifier le relevé #{{ $releve->id }}

+

+ Source : {{ $source->nom }} +

+
+
+ +
+
+
+ @csrf @method('PUT') + @include('releves._form', ['releve' => $releve]) +
+ + Annuler +
+
+
+
+
diff --git a/resources/views/releves/index.blade.php b/resources/views/releves/index.blade.php new file mode 100644 index 0000000..1ac5e93 --- /dev/null +++ b/resources/views/releves/index.blade.php @@ -0,0 +1,120 @@ + + +
+
+

Relevés — {{ $source->nom }}

+

+ Type : {{ $source->sourceType->nom }} + @if($source->cote) · Cote : {{ $source->cote }} @endif +

+
+
+ ← Source + + ↓ GEDCOM + + @can('create', [App\Models\Releve::class, $source]) + + + Nouveau relevé + + @endcan +
+
+
+ +
+ @if(session('success')) +
{{ session('success') }}
+ @endif + + @php + // Colonnes à afficher : les 4 premiers champs du type de source + $colonnes = $source->sourceType->fields->take(5); + @endphp + +
+
+ + + + @foreach($colonnes as $col) + + @endforeach + + + + + + + @forelse($releves as $releve) + + @foreach($colonnes as $col) + + @endforeach + + + + + @empty + + + + @endforelse + +
+ {{ $col->label }} + Saisi parDate
+ @php $val = $releve->data[$col->name] ?? null; @endphp + @if(is_array($val)) + {{ $val['valeur'] ?? '' }} + @if(!empty($val['calendrier']) && $val['calendrier'] !== 'gregorien') + ({{ $val['calendrier'] }}) + @endif + @elseif(is_bool($val)) + {{ $val ? 'Oui' : 'Non' }} + @else + {{ $val ?? '—' }} + @endif + {{ $releve->createur?->name ?? '—' }}{{ $releve->created_at->format('d/m/Y') }} + Voir + @can('update', $releve) + Modifier + @endcan + @can('delete', $releve) +
+ @csrf @method('DELETE') + +
+ @endcan +
+ Aucun relevé pour cette source. +
+
+ + {{-- Navigation curseur (keyset pagination) --}} + @if($releves->hasPages()) +
+
+ @if($releves->onFirstPage()) + ← Précédent + @else + ← Précédent + @endif +
+
+ @if($releves->hasMorePages()) + Suivant → + @else + Suivant → + @endif +
+
+ @endif +
+
+
diff --git a/resources/views/releves/show.blade.php b/resources/views/releves/show.blade.php new file mode 100644 index 0000000..1e47bd1 --- /dev/null +++ b/resources/views/releves/show.blade.php @@ -0,0 +1,74 @@ + + +
+
+

Relevé #{{ $releve->id }}

+

+ Source : {{ $source->nom }} +

+
+
+ @can('update', $releve) + + Modifier + + @endcan + @can('delete', $releve) +
+ @csrf @method('DELETE') + +
+ @endcan +
+
+
+ +
+ @if(session('success')) +
{{ session('success') }}
+ @endif + + {{-- Champs du relevé --}} +
+ @foreach($source->sourceType->fields as $field) + @php $val = $releve->data[$field->name] ?? null; @endphp +
+
{{ $field->label }}
+
+ @if($val === null || $val === '') + + @elseif(is_array($val)) + {{ $val['valeur'] ?? '—' }} + @if(!empty($val['calendrier']) && $val['calendrier'] !== 'gregorien') + ({{ $val['calendrier'] }}) + @endif + @elseif(is_bool($val)) + + {{ $val ? 'Oui' : 'Non' }} + + @else + {{ $val }} + @endif +
+
+ @endforeach +
+ + {{-- Méta-données de saisie --}} +
+

Saisi par {{ $releve->createur?->name ?? '?' }} le {{ $releve->created_at->format('d/m/Y à H:i') }}

+ @if($releve->updated_at != $releve->created_at) +

Modifié par {{ $releve->modificateur?->name ?? '?' }} le {{ $releve->updated_at->format('d/m/Y à H:i') }}

+ @endif +
+ + +
+
diff --git a/resources/views/sources/_form.blade.php b/resources/views/sources/_form.blade.php index 13e0680..56da08a 100644 --- a/resources/views/sources/_form.blade.php +++ b/resources/views/sources/_form.blade.php @@ -41,6 +41,48 @@ + {{-- Lieu géographique couvert par la source --}} + @php + $lieuIdForm = old('lieu_id', $source?->lieu_id); + $lieuDispForm = ''; + if ($lieuIdForm) { + $lieuObj = ($source?->lieu_id == $lieuIdForm && $source?->lieu) + ? $source->lieu + : \App\Models\Lieu::find($lieuIdForm, ['id', 'nom', 'nom_long']); + $lieuDispForm = $lieuObj?->nom_long ?? $lieuObj?->nom ?? ''; + } + @endphp +
+ + @error('lieu_id')

{{ $message }}

@enderror +
+ + {{-- Période couverte --}} +
+
+ + + @error('annee_debut')

{{ $message }}

@enderror +
+
+ + + @error('annee_fin')

{{ $message }}

@enderror +
+
+
diff --git a/resources/views/sources/index.blade.php b/resources/views/sources/index.blade.php index bfea852..c7d9740 100644 --- a/resources/views/sources/index.blade.php +++ b/resources/views/sources/index.blade.php @@ -9,16 +9,103 @@
-
- @if(session('success'))
{{ session('success') }}
@endif +
+ @if(session('success')) +
{{ session('success') }}
+ @endif + {{-- Filtres --}} + @php + $hasFilters = request()->anyFilled(['status', 'source_type_id', 'lieu_id', 'annee_debut', 'annee_fin']); + @endphp +
+
+
+ + {{-- Statut --}} +
+ + +
+ + {{-- Type de source --}} +
+ + +
+ + {{-- Année de début --}} +
+ + +
+ + {{-- Année de fin --}} +
+ + +
+
+ + {{-- Lieu --}} +
+ +
+ +
+ + @if($hasFilters) + + Effacer les filtres + + + filtres actifs + + @endif +
+
+
+ + {{-- Tableau --}}
- +
+ + @@ -34,20 +121,30 @@ 'termine' => 'bg-green-100 text-green-700', ]; $color = $statusColors[$source->status->value] ?? 'bg-gray-100 text-gray-600'; + $periode = match(true) { + $source->annee_debut && $source->annee_fin => $source->annee_debut . ' – ' . $source->annee_fin, + (bool)$source->annee_debut => 'depuis ' . $source->annee_debut, + (bool)$source->annee_fin => 'jusqu\'en ' . $source->annee_fin, + default => '—', + }; @endphp - + - - + + + + @empty - + + + @endforelse
Nom Type StatutLieuPériode Relevés Dépôt
{{ $source->nom }} @if($source->cote) {{ $source->cote }} @endif {{ $source->sourceType->nom }}{{ $source->sourceType->nom }} {{ $source->status->label() }} {{ $source->releves_count }}{{ $source->depot?->nom ?? '—' }} + {{ $source->lieu?->nom ?? '—' }} + {{ $periode }}{{ $source->releves_count }}{{ $source->depot?->nom ?? '—' }} @can('update', $source) Modifier @@ -55,11 +152,18 @@
Aucune source disponible.
+ @if($hasFilters) Aucune source ne correspond aux filtres. + @else Aucune source disponible. @endif +
- @if($sources->hasPages())
{{ $sources->links() }}
@endif + @if($sources->hasPages()) +
{{ $sources->links() }}
+ @endif
diff --git a/resources/views/sources/show.blade.php b/resources/views/sources/show.blade.php index d9b12c9..007cfa8 100644 --- a/resources/views/sources/show.blade.php +++ b/resources/views/sources/show.blade.php @@ -135,12 +135,21 @@

Relevés ({{ $source->releves->count() }})

- {{-- Lien activé à l'étape 6 --}} + @can('create', [App\Models\Releve::class, $source]) + + + Nouveau relevé + + @endcan
@if($source->releves->isEmpty())

Aucun relevé pour cette source.

@else -

{{ $source->releves->count() }} relevé(s) enregistré(s).

+

+ + Voir les {{ $source->releves->count() }} relevé(s) → + +

@endif
diff --git a/routes/admin.php b/routes/admin.php index 9fd020c..c7eb5da 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -1,11 +1,16 @@ prefix('admin')->name('admin.')->group(function () { + Route::resource('lieu-types', LieuTypeController::class) + ->parameters(['lieu-types' => 'lieuType']) + ->except(['show']); + Route::resource('sections', SectionController::class); Route::post('sections/{section}/membres', [SectionController::class, 'addMembre'])->name('sections.membres.add'); Route::delete('sections/{section}/membres/{user}', [SectionController::class, 'removeMembre'])->name('sections.membres.remove'); diff --git a/routes/web.php b/routes/web.php index bce9ef9..a4b6ec7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,11 @@ group(function () { Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); - Route::resource('lieux', LieuController::class); + Route::get('lieux/search', [LieuController::class, 'search'])->name('lieux.search'); + Route::resource('lieux', LieuController::class)->parameters(['lieux' => 'lieu']); Route::resource('sources', SourceController::class); Route::post('sources/{source}/membres', [SourceController::class, 'addMembre'])->name('sources.membres.add'); Route::delete('sources/{source}/membres/{user}', [SourceController::class, 'removeMembre'])->name('sources.membres.remove'); Route::post('sources/{source}/transition', [SourceController::class, 'transition'])->name('sources.transition'); + + Route::resource('sources.releves', ReleveController::class) + ->shallow() + ->parameters(['releves' => 'releve']); + + Route::get('recherche', [RechercheController::class, 'index'])->name('recherche'); + Route::get('export/source/{source}', [ExportController::class, 'source'])->name('export.source'); + Route::get('export/recherche', [ExportController::class, 'recherche'])->name('export.recherche'); + + Route::get('notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::post('notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.read'); + Route::post('notifications/read-all', [NotificationController::class, 'markAllAsRead'])->name('notifications.read-all'); }); require __DIR__.'/auth.php';