É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\Controllers\Controller;
use App\Http\Requests\Admin\StoreSectionRequest; use App\Http\Requests\Admin\StoreSectionRequest;
use App\Http\Requests\Admin\UpdateSectionRequest; use App\Http\Requests\Admin\UpdateSectionRequest;
use App\Models\Lieu;
use App\Models\Section; use App\Models\Section;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -22,8 +21,7 @@ class SectionController extends Controller
public function create(): View public function create(): View
{ {
$lieux = Lieu::orderBy('nom_long')->get(['id', 'nom_long', 'nom']); return view('admin.sections.create');
return view('admin.sections.create', compact('lieux'));
} }
public function store(StoreSectionRequest $request): RedirectResponse public function store(StoreSectionRequest $request): RedirectResponse
@@ -42,8 +40,8 @@ class SectionController extends Controller
public function edit(Section $section): View public function edit(Section $section): View
{ {
$lieux = Lieu::orderBy('nom_long')->get(['id', 'nom_long', 'nom']); $section->load('lieu');
return view('admin.sections.edit', compact('section', 'lieux')); return view('admin.sections.edit', compact('section'));
} }
public function update(UpdateSectionRequest $request, Section $section): RedirectResponse public function update(UpdateSectionRequest $request, Section $section): RedirectResponse
+1 -1
View File
@@ -4,5 +4,5 @@ namespace App\Http\Controllers;
abstract class Controller 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\StoreLieuRequest;
use App\Http\Requests\UpdateLieuRequest; use App\Http\Requests\UpdateLieuRequest;
use App\Models\Lieu; use App\Models\Lieu;
use App\Models\LieuType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View; use Illuminate\View\View;
class LieuController extends Controller 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); $this->authorize('viewAny', Lieu::class);
// Arbre complet trié par nom_long pour l'affichage $query = Lieu::with(['parent', 'lieuType'])->orderBy('nom_long');
$lieux = Lieu::with('parent')
->orderBy('nom_long')
->paginate(50);
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 public function create(): View
{ {
$this->authorize('create', Lieu::class); $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 public function store(StoreLieuRequest $request): RedirectResponse
@@ -43,7 +90,7 @@ class LieuController extends Controller
{ {
$this->authorize('view', $lieu); $this->authorize('view', $lieu);
$lieu->load('parent', 'enfants'); $lieu->load('parent', 'enfants', 'lieuType');
return view('lieux.show', compact('lieu')); return view('lieux.show', compact('lieu'));
} }
@@ -52,20 +99,15 @@ class LieuController extends Controller
{ {
$this->authorize('update', $lieu); $this->authorize('update', $lieu);
// Exclure le lieu lui-même et ses descendants pour éviter les cycles $lieu->load('parent', 'lieuType');
$descendants = $this->getDescendantIds($lieu); $lieuTypes = LieuType::orderBy('ordre')->get(['id', 'nom']);
$parents = Lieu::whereNotIn('id', [...$descendants, $lieu->id])
->orderBy('nom_long')
->get(['id', 'nom_long']);
return view('lieux.edit', compact('lieu', 'parents')); return view('lieux.edit', compact('lieu', 'lieuTypes'));
} }
public function update(UpdateLieuRequest $request, Lieu $lieu): RedirectResponse public function update(UpdateLieuRequest $request, Lieu $lieu): RedirectResponse
{ {
$lieu->update($request->validated()); $lieu->update($request->validated());
// Recalculer nom_long des enfants en cascade
$this->recalculerEnfants($lieu); $this->recalculerEnfants($lieu);
return redirect()->route('lieux.show', $lieu) return redirect()->route('lieux.show', $lieu)
@@ -86,12 +128,27 @@ class LieuController extends Controller
->with('success', 'Lieu supprimé.'); ->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 private function getDescendantIds(Lieu $lieu): array
{ {
$ids = []; $ids = [];
foreach ($lieu->enfants as $enfant) { foreach ($lieu->enfants as $enfant) {
$ids[] = $enfant->id; $ids[] = $enfant->id;
$ids = array_merge($ids, $this->getDescendantIds($enfant)); $ids = array_merge($ids, $this->getDescendantIds($enfant));
} }
return $ids; return $ids;
} }
@@ -100,7 +157,7 @@ class LieuController extends Controller
{ {
$lieu->load('enfants'); $lieu->load('enfants');
foreach ($lieu->enfants as $enfant) { foreach ($lieu->enfants as $enfant) {
$enfant->update([]); // déclenche le booted() hook qui recalcule nom_long $enfant->update([]);
$this->recalculerEnfants($enfant); $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\StoreSourceRequest;
use App\Http\Requests\UpdateSourceRequest; use App\Http\Requests\UpdateSourceRequest;
use App\Models\Depot; use App\Models\Depot;
use App\Models\Lieu;
use App\Models\Source; use App\Models\Source;
use App\Models\SourceType; use App\Models\SourceType;
use App\Models\User; use App\Models\User;
use App\Notifications\SourceAValiderNotification;
use App\Notifications\SourceRejeteeNotification;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View; use Illuminate\View\View;
class SourceController extends Controller class SourceController extends Controller
{ {
public function index(): View public function index(Request $request): View
{ {
$this->authorize('viewAny', Source::class); $this->authorize('viewAny', Source::class);
$user = auth()->user(); $user = auth()->user();
$query = Source::with(['sourceType', 'depot']) $query = Source::with(['sourceType', 'depot', 'lieu'])
->withCount('releves'); ->withCount('releves');
if (! $user->isSectionManager()) { if (! $user->isSectionManager()) {
// Membre : sources terminées + sources assignées
$assignedIds = $user->sourcesAssignees()->pluck('sources.id'); $assignedIds = $user->sourcesAssignees()->pluck('sources.id');
$query->where(function ($q) use ($assignedIds) { $query->where(function ($q) use ($assignedIds) {
$q->where('status', SourceStatus::Termine) $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 public function create(): View
@@ -71,6 +121,7 @@ class SourceController extends Controller
{ {
$this->authorize('update', $source); $this->authorize('update', $source);
$source->loadMissing('lieu');
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']); $sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
$depots = Depot::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.'); return back()->with('error', 'Transition non autorisée.');
} }
$previousStatus = $source->status;
$source->update(['status' => $newStatus]); $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()); 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 [ return [
'nom' => ['required', 'string', 'max:255'], 'nom' => ['required', 'string', 'max:255'],
'lieu_type_id' => ['required', 'integer', 'exists:lieu_types,id'],
'code' => ['nullable', 'string', 'max:20'], 'code' => ['nullable', 'string', 'max:20'],
'lieu_parent_id'=> ['nullable', 'integer', 'exists:lieux,id'], 'lieu_parent_id'=> ['nullable', 'integer', 'exists:lieux,id'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'], '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'], 'description' => ['nullable', 'string'],
'source_type_id' => ['required', 'integer', 'exists:source_types,id'], 'source_type_id' => ['required', 'integer', 'exists:source_types,id'],
'depot_id' => ['nullable', 'integer', 'exists:depots,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'], 'cote' => ['nullable', 'string', 'max:255'],
'auteur' => ['nullable', 'string', 'max:255'], 'auteur' => ['nullable', 'string', 'max:255'],
]; ];
+1
View File
@@ -17,6 +17,7 @@ class UpdateLieuRequest extends FormRequest
return [ return [
'nom' => ['required', 'string', 'max:255'], 'nom' => ['required', 'string', 'max:255'],
'lieu_type_id' => ['required', 'integer', 'exists:lieu_types,id'],
'code' => ['nullable', 'string', 'max:20'], 'code' => ['nullable', 'string', 'max:20'],
// Interdit de se choisir soi-même ou un descendant comme parent // Interdit de se choisir soi-même ou un descendant comme parent
'lieu_parent_id'=> ['nullable', 'integer', 'exists:lieux,id', "not_in:{$lieuId}"], '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'], 'description' => ['nullable', 'string'],
'source_type_id' => ['required', 'integer', 'exists:source_types,id'], 'source_type_id' => ['required', 'integer', 'exists:source_types,id'],
'depot_id' => ['nullable', 'integer', 'exists:depots,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'], 'cote' => ['nullable', 'string', 'max:255'],
'auteur' => ['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 $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 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 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 = [ protected $casts = [
'status' => SourceStatus::class, 'status' => SourceStatus::class,
@@ -26,6 +26,11 @@ class Source extends Model
return $this->belongsTo(Depot::class); return $this->belongsTo(Depot::class);
} }
public function lieu(): BelongsTo
{
return $this->belongsTo(Lieu::class);
}
public function membres(): BelongsToMany public function membres(): BelongsToMany
{ {
return $this->belongsToMany(User::class, 'source_user'); 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;
}
}
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('lieu_types', function (Blueprint $table) {
$table->id();
$table->string('nom')->unique();
$table->unsignedSmallInteger('ordre')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('lieu_types');
}
};
@@ -10,10 +10,11 @@ return new class extends Migration
{ {
Schema::create('lieux', function (Blueprint $table) { Schema::create('lieux', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('lieu_type_id')->nullable()->constrained('lieu_types')->nullOnDelete();
$table->string('nom'); $table->string('nom');
$table->string('code')->nullable(); $table->string('code')->nullable();
$table->foreignId('lieu_parent_id')->nullable()->constrained('lieux')->nullOnDelete(); $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('latitude', 10, 7)->nullable();
$table->decimal('longitude', 10, 7)->nullable(); $table->decimal('longitude', 10, 7)->nullable();
$table->text('note')->nullable(); $table->text('note')->nullable();
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('sources', function (Blueprint $table) {
$table->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']);
});
}
};
+150 -10
View File
@@ -2,24 +2,164 @@
namespace Database\Seeders; 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 App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void 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([ $responsable = User::create([
'name' => 'Test User', 'name' => 'Responsable Bordeaux',
'email' => 'test@example.com', '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 18201830',
'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 18151820',
'source_type_id' => $stMariage->id,
'depot_id' => $depot->id,
'cote' => '4E 750',
'status' => SourceStatus::AValider,
]);
// ── Source : à faire ─────────────────────────────────────────────────
Source::create([
'nom' => 'Naissances Bègles 18301840',
'source_type_id' => $stNaissance->id,
'depot_id' => $depot->id,
'cote' => '4E 820',
'status' => SourceStatus::AFaire,
]); ]);
} }
} }
@@ -0,0 +1,17 @@
<div class="space-y-5">
<div>
<label for="nom" class="block text-sm font-medium text-gray-700">Nom <span class="text-red-500">*</span></label>
<input type="text" id="nom" name="nom" value="{{ old('nom', $lieuType?->nom) }}" required
placeholder="ex : Pays, Région, Département, Ville…"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 @error('nom') border-red-500 @enderror">
@error('nom') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="ordre" class="block text-sm font-medium text-gray-700">Ordre d'affichage <span class="text-red-500">*</span></label>
<input type="number" id="ordre" name="ordre" value="{{ old('ordre', $lieuType?->ordre ?? 0) }}"
min="0" max="999" required
class="mt-1 block w-32 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-gray-400">Les valeurs les plus basses apparaissent en premier (ex : Pays=0, Région=10, Département=20, Ville=30)</p>
</div>
</div>
@@ -0,0 +1,15 @@
<x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold text-gray-800">Nouveau type de lieu</h2></x-slot>
<div class="py-8 max-w-lg mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white shadow rounded-lg p-6">
<form method="POST" action="{{ route('admin.lieu-types.store') }}">
@csrf
@include('admin.lieu-types._form', ['lieuType' => null])
<div class="mt-6 flex gap-4">
<button type="submit" class="px-5 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">Créer</button>
<a href="{{ route('admin.lieu-types.index') }}" class="text-sm text-gray-500 self-center hover:text-gray-700">Annuler</a>
</div>
</form>
</div>
</div>
</x-app-layout>
@@ -0,0 +1,15 @@
<x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold text-gray-800">Modifier : {{ $lieuType->nom }}</h2></x-slot>
<div class="py-8 max-w-lg mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white shadow rounded-lg p-6">
<form method="POST" action="{{ route('admin.lieu-types.update', $lieuType) }}">
@csrf @method('PUT')
@include('admin.lieu-types._form', ['lieuType' => $lieuType])
<div class="mt-6 flex gap-4">
<button type="submit" class="px-5 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">Enregistrer</button>
<a href="{{ route('admin.lieu-types.index') }}" class="text-sm text-gray-500 self-center hover:text-gray-700">Annuler</a>
</div>
</form>
</div>
</div>
</x-app-layout>
@@ -0,0 +1,56 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-800">Types de lieux</h2>
<a href="{{ route('admin.lieu-types.create') }}"
class="px-4 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700">+ Nouveau type</a>
</div>
</x-slot>
<div class="py-8 max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
@foreach(['success','error'] as $flash)
@if(session($flash))
<div class="mb-4 p-4 rounded-md {{ $flash === 'success' ? 'bg-green-50 border border-green-200 text-green-800' : 'bg-red-50 border border-red-200 text-red-800' }}">
{{ session($flash) }}
</div>
@endif
@endforeach
<div class="bg-white shadow rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Ordre</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nom</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Lieux</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse($lieuTypes as $lt)
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm text-gray-400 w-16">{{ $lt->ordre }}</td>
<td class="px-6 py-4 font-medium text-gray-900">{{ $lt->nom }}</td>
<td class="px-6 py-4 text-sm text-gray-500">{{ $lt->lieux_count }}</td>
<td class="px-6 py-4 text-right text-sm space-x-3">
<a href="{{ route('admin.lieu-types.edit', $lt) }}"
class="text-gray-600 hover:text-indigo-600">Modifier</a>
<form method="POST" action="{{ route('admin.lieu-types.destroy', $lt) }}" class="inline"
x-data @submit.prevent="if(confirm('Supprimer ce type ?')) $el.submit()">
@csrf @method('DELETE')
<button type="submit" class="text-red-500 hover:text-red-700">Supprimer</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-6 py-10 text-center text-gray-400">
Aucun type de lieu défini.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</x-app-layout>
+8 -12
View File
@@ -6,18 +6,14 @@
@error('nom') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror @error('nom') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
<div> <x-lieu-picker
<label for="lieu_id" class="block text-sm font-medium text-gray-700">Lieu de rattachement</label> name="lieu_id"
<select id="lieu_id" name="lieu_id" label="Lieu de rattachement"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"> :value="old('lieu_id', $section?->lieu_id)"
<option value=""> Aucun </option> :display-value="old('lieu_id')
@foreach($lieux as $lieu) ? ''
<option value="{{ $lieu->id }}" {{ old('lieu_id', $section?->lieu_id) == $lieu->id ? 'selected' : '' }}> : ($section?->lieu?->nom_long ?? $section?->lieu?->nom ?? '')"
{{ $lieu->nom_long ?? $lieu->nom }} />
</option>
@endforeach
</select>
</div>
<div> <div>
<label for="adresse" class="block text-sm font-medium text-gray-700">Adresse</label> <label for="adresse" class="block text-sm font-medium text-gray-700">Adresse</label>
@@ -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…',
])
<div
x-data="{
open: false,
search: '',
results: [],
loading: false,
selected: {
id: {{ $value ? (int)$value : 'null' }},
name: {{ json_encode($displayValue ?: '') }}
},
debounceTimer: null,
openModal() {
this.open = true;
this.search = '';
this.results = [];
this.$nextTick(() => this.$refs.searchInput?.focus());
},
onSearchInput() {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.fetchResults(), 220);
},
async fetchResults() {
if (this.search.length < 1) { this.results = []; return; }
this.loading = true;
try {
const res = await fetch(
'{{ route('lieux.search') }}?q=' + encodeURIComponent(this.search),
{ headers: { 'X-Requested-With': 'XMLHttpRequest' } }
);
this.results = await res.json();
} finally {
this.loading = false;
}
},
select(lieu) {
this.selected = { id: lieu.id, name: lieu.nom_long };
this.open = false;
this.search = '';
this.results = [];
},
clear() {
this.selected = { id: null, name: '' };
}
}"
@keydown.escape.window="open = false"
>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ $label }}
@if($required) <span class="text-red-500">*</span> @endif
</label>
{{-- Champ hidden pour la valeur soumise --}}
<input type="hidden" name="{{ $name }}" :value="selected.id ?? ''">
{{-- Affichage du lieu sélectionné + boutons --}}
<div class="flex gap-2">
<button
type="button"
@click="openModal()"
class="flex-1 text-left px-3 py-2 border border-gray-300 rounded-md bg-white text-sm shadow-sm
hover:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors
@error($name) border-red-500 @enderror"
>
<span x-show="selected.id" class="text-gray-900" x-text="selected.name"></span>
<span x-show="!selected.id" class="text-gray-400">{{ $placeholder }}</span>
</button>
<button
type="button"
x-show="selected.id"
@click="clear()"
title="Effacer"
class="px-2 py-2 text-gray-400 hover:text-red-500 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
@error($name)
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
{{-- Modale de recherche --}}
<div
x-show="open"
x-cloak
class="fixed inset-0 z-50 flex items-start justify-center pt-24 px-4"
@click.self="open = false"
>
{{-- Fond semi-transparent --}}
<div class="absolute inset-0 bg-black/40" @click="open = false"></div>
{{-- Panneau --}}
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-lg z-10 overflow-hidden">
{{-- En-tête --}}
<div class="px-4 py-3 border-b border-gray-100 flex items-center gap-3">
<svg class="w-5 h-5 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
<input
x-ref="searchInput"
type="text"
x-model="search"
@input="onSearchInput()"
placeholder="Nom, code INSEE…"
class="flex-1 text-sm outline-none placeholder-gray-400"
>
<button type="button" @click="open = false"
class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{{-- Résultats --}}
<div class="max-h-80 overflow-y-auto">
{{-- Chargement --}}
<div x-show="loading" class="px-4 py-6 text-center text-sm text-gray-400">
Recherche…
</div>
{{-- Aucun résultat --}}
<div x-show="!loading && search.length > 0 && results.length === 0"
class="px-4 py-6 text-center text-sm text-gray-400">
Aucun lieu trouvé pour « <span x-text="search"></span> »
</div>
{{-- Invite initiale --}}
<div x-show="!loading && search.length === 0"
class="px-4 py-6 text-center text-sm text-gray-400">
Saisissez au moins une lettre pour rechercher
</div>
{{-- Liste --}}
<ul x-show="results.length > 0">
<template x-for="lieu in results" :key="lieu.id">
<li>
<button
type="button"
@click="select(lieu)"
class="w-full text-left px-4 py-3 hover:bg-indigo-50 flex items-center justify-between gap-3 transition-colors"
>
<div>
<span class="text-sm font-medium text-gray-900" x-text="lieu.nom_long"></span>
<span x-show="lieu.code"
class="ml-2 text-xs text-gray-400"
x-text="lieu.code"></span>
</div>
<span x-show="lieu.type"
class="shrink-0 text-xs px-2 py-0.5 bg-gray-100 text-gray-500 rounded-full"
x-text="lieu.type"></span>
</button>
</li>
</template>
</ul>
</div>
@can('create', App\Models\Lieu::class)
{{-- Pied : créer un nouveau lieu --}}
<div class="border-t border-gray-100 px-4 py-2.5">
<a href="{{ route('lieux.create') }}" target="_blank"
class="text-xs text-indigo-600 hover:underline">
+ Créer un nouveau lieu
</a>
</div>
@endcan
</div>
</div>
</div>
+96 -25
View File
@@ -1,30 +1,86 @@
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100"> <nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16"> <div class="flex justify-between h-16">
<div class="flex"> <div class="flex">
<!-- Logo --> <!-- Logo -->
<div class="shrink-0 flex items-center"> <div class="shrink-0 flex items-center">
<a href="{{ route('dashboard') }}"> <a href="{{ route('dashboard') }}" class="font-semibold text-gray-800 text-lg">
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" /> MesRelevés
</a> </a>
</div> </div>
<!-- Navigation Links --> <!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex"> <div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }} Tableau de bord
</x-nav-link> </x-nav-link>
<x-nav-link :href="route('sources.index')" :active="request()->routeIs('sources.*') && !request()->routeIs('sources.releves.*')">
Sources
</x-nav-link>
<x-nav-link :href="route('lieux.index')" :active="request()->routeIs('lieux.*')">
Lieux
</x-nav-link>
<x-nav-link :href="route('recherche')" :active="request()->routeIs('recherche')">
Recherche
</x-nav-link>
@if(auth()->user()->isSectionManager())
<!-- Menu Administration -->
<div class="hidden sm:flex sm:items-center" x-data="{ adminOpen: false }">
<button @click="adminOpen = !adminOpen"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 transition duration-150 ease-in-out
{{ request()->routeIs('admin.*') ? 'border-indigo-400 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
Administration
<svg class="ms-1 h-4 w-4 fill-current" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<div x-show="adminOpen" @click.outside="adminOpen = false" x-cloak
class="absolute top-14 mt-1 w-48 bg-white rounded-md shadow-lg border border-gray-100 z-50">
<a href="{{ route('admin.sections.index') }}"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Sections</a>
@if(auth()->user()->isAdmin())
<a href="{{ route('admin.depots.index') }}"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Dépôts d'archives</a>
<a href="{{ route('admin.source-types.index') }}"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Types de sources</a>
<a href="{{ route('admin.lieu-types.index') }}"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Types de lieux</a>
@endif
</div>
</div>
@endif
</div> </div>
</div> </div>
<!-- Cloche notifications -->
<div class="hidden sm:flex sm:items-center sm:ms-4">
@php $unreadCount = auth()->user()->unreadNotifications->count(); @endphp
<a href="{{ route('notifications.index') }}"
class="relative p-2 text-gray-500 hover:text-indigo-600 transition-colors"
title="Notifications">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
@if($unreadCount > 0)
<span class="absolute top-1 right-1 inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-red-500 rounded-full">
{{ $unreadCount > 9 ? '9+' : $unreadCount }}
</span>
@endif
</a>
</div>
<!-- Settings Dropdown --> <!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ms-6"> <div class="hidden sm:flex sm:items-center sm:ms-2">
<x-dropdown align="right" width="48"> <x-dropdown align="right" width="48">
<x-slot name="trigger"> <x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150"> <button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div>{{ Auth::user()->name }}</div> <div>{{ Auth::user()->name }}</div>
<div class="ms-1"> <div class="ms-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
@@ -34,18 +90,17 @@
</x-slot> </x-slot>
<x-slot name="content"> <x-slot name="content">
<div class="px-4 py-2 text-xs text-gray-400 border-b border-gray-100">
{{ Auth::user()->role->label() }}
</div>
<x-dropdown-link :href="route('profile.edit')"> <x-dropdown-link :href="route('profile.edit')">
{{ __('Profile') }} Mon profil
</x-dropdown-link> </x-dropdown-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
<x-dropdown-link :href="route('logout')" <x-dropdown-link :href="route('logout')"
onclick="event.preventDefault(); onclick="event.preventDefault(); this.closest('form').submit();">
this.closest('form').submit();"> Se déconnecter
{{ __('Log Out') }}
</x-dropdown-link> </x-dropdown-link>
</form> </form>
</x-slot> </x-slot>
@@ -54,7 +109,7 @@
<!-- Hamburger --> <!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden"> <div class="-me-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out"> <button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none transition duration-150 ease-in-out">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24"> <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> <path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -68,8 +123,30 @@
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden"> <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1"> <div class="pt-2 pb-3 space-y-1">
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }} Tableau de bord
</x-responsive-nav-link> </x-responsive-nav-link>
<x-responsive-nav-link :href="route('sources.index')" :active="request()->routeIs('sources.*')">
Sources
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('lieux.index')" :active="request()->routeIs('lieux.*')">
Lieux
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('recherche')" :active="request()->routeIs('recherche')">
Recherche
</x-responsive-nav-link>
@if(auth()->user()->isSectionManager())
<x-responsive-nav-link :href="route('admin.sections.index')" :active="request()->routeIs('admin.sections.*')">
Sections
</x-responsive-nav-link>
@if(auth()->user()->isAdmin())
<x-responsive-nav-link :href="route('admin.depots.index')" :active="request()->routeIs('admin.depots.*')">
Dépôts d'archives
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('admin.source-types.index')" :active="request()->routeIs('admin.source-types.*')">
Types de sources
</x-responsive-nav-link>
@endif
@endif
</div> </div>
<!-- Responsive Settings Options --> <!-- Responsive Settings Options -->
@@ -77,21 +154,15 @@
<div class="px-4"> <div class="px-4">
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div> <div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div> <div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
<div class="text-xs text-gray-400">{{ Auth::user()->role->label() }}</div>
</div> </div>
<div class="mt-3 space-y-1"> <div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('profile.edit')"> <x-responsive-nav-link :href="route('profile.edit')">Mon profil</x-responsive-nav-link>
{{ __('Profile') }}
</x-responsive-nav-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
<x-responsive-nav-link :href="route('logout')" <x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault(); onclick="event.preventDefault(); this.closest('form').submit();">
this.closest('form').submit();"> Se déconnecter
{{ __('Log Out') }}
</x-responsive-nav-link> </x-responsive-nav-link>
</form> </form>
</div> </div>
+31 -15
View File
@@ -9,6 +9,27 @@
@error('nom') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror @error('nom') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
{{-- Type de lieu --}}
<div>
<label for="lieu_type_id" class="block text-sm font-medium text-gray-700">Type <span class="text-red-500">*</span></label>
<select id="lieu_type_id" name="lieu_type_id" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 @error('lieu_type_id') border-red-500 @enderror">
<option value=""> Choisir un type </option>
@foreach($lieuTypes as $lt)
<option value="{{ $lt->id }}" {{ old('lieu_type_id', $lieu?->lieu_type_id) == $lt->id ? 'selected' : '' }}>
{{ $lt->nom }}
</option>
@endforeach
</select>
@error('lieu_type_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
@if($lieuTypes->isEmpty())
<p class="mt-1 text-sm text-amber-600">
Aucun type défini.
<a href="{{ route('admin.lieu-types.create') }}" class="underline">Créer des types </a>
</p>
@endif
</div>
{{-- Code --}} {{-- Code --}}
<div> <div>
<label for="code" class="block text-sm font-medium text-gray-700">Code (INSEE, postal…)</label> <label for="code" class="block text-sm font-medium text-gray-700">Code (INSEE, postal…)</label>
@@ -18,21 +39,16 @@
maxlength="20"> maxlength="20">
</div> </div>
{{-- Parent --}} {{-- Lieu parent --}}
<div> <x-lieu-picker
<label for="lieu_parent_id" class="block text-sm font-medium text-gray-700">Lieu parent</label> name="lieu_parent_id"
<select id="lieu_parent_id" name="lieu_parent_id" label="Lieu parent"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"> :value="old('lieu_parent_id', $lieu?->lieu_parent_id)"
<option value=""> Aucun (lieu racine) </option> :display-value="old('lieu_parent_id')
@foreach($parents as $parent) ? ''
<option value="{{ $parent->id }}" : ($lieu?->parent?->nom_long ?? $lieu?->parent?->nom ?? '')"
{{ old('lieu_parent_id', $lieu?->lieu_parent_id) == $parent->id ? 'selected' : '' }}> placeholder="— Aucun (lieu racine) —"
{{ $parent->nom_long ?? $parent->nom }} />
</option>
@endforeach
</select>
@error('lieu_parent_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Coordonnées --}} {{-- Coordonnées --}}
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="bg-white shadow rounded-lg p-6"> <div class="bg-white shadow rounded-lg p-6">
<form method="POST" action="{{ route('lieux.store') }}"> <form method="POST" action="{{ route('lieux.store') }}">
@csrf @csrf
@include('lieux._form', ['lieu' => null, 'parents' => $parents]) @include('lieux._form', ['lieu' => null])
<div class="mt-6 flex items-center gap-4"> <div class="mt-6 flex items-center gap-4">
<button type="submit" <button type="submit"
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="bg-white shadow rounded-lg p-6"> <div class="bg-white shadow rounded-lg p-6">
<form method="POST" action="{{ route('lieux.update', $lieu) }}"> <form method="POST" action="{{ route('lieux.update', $lieu) }}">
@csrf @method('PUT') @csrf @method('PUT')
@include('lieux._form', ['lieu' => $lieu, 'parents' => $parents]) @include('lieux._form', ['lieu' => $lieu])
<div class="mt-6 flex items-center gap-4"> <div class="mt-6 flex items-center gap-4">
<button type="submit" <button type="submit"
+66 -8
View File
@@ -11,24 +11,78 @@
</div> </div>
</x-slot> </x-slot>
<div class="py-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="py-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
@if(session('success')) @if(session('success'))
<div class="mb-4 p-4 bg-green-50 border border-green-200 text-green-800 rounded-md"> <div class="p-4 bg-green-50 border border-green-200 text-green-800 rounded-md">{{ session('success') }}</div>
{{ session('success') }}
</div>
@endif @endif
@if(session('error')) @if(session('error'))
<div class="mb-4 p-4 bg-red-50 border border-red-200 text-red-800 rounded-md"> <div class="p-4 bg-red-50 border border-red-200 text-red-800 rounded-md">{{ session('error') }}</div>
{{ session('error') }}
</div>
@endif @endif
{{-- Filtres --}}
@php $hasFilters = request()->anyFilled(['lieu_type_id', 'q', 'lieu_id']); @endphp
<div class="bg-white shadow rounded-lg p-5">
<form method="GET" action="{{ route('lieux.index') }}">
<div class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-medium text-gray-600 mb-1">Recherche</label>
<input type="text" name="q" value="{{ request('q') }}"
placeholder="Nom, code INSEE…"
class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div class="w-52">
<label class="block text-xs font-medium text-gray-600 mb-1">Type de lieu</label>
<select name="lieu_type_id"
class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value=""> Tous les types </option>
@foreach($lieuTypes as $lt)
<option value="{{ $lt->id }}" {{ request('lieu_type_id') == $lt->id ? 'selected' : '' }}>
{{ $lt->nom }}
</option>
@endforeach
</select>
</div>
<div class="flex items-center gap-3 self-end">
<button type="submit"
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
Filtrer
</button>
@if($hasFilters)
<a href="{{ route('lieux.index') }}"
class="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50">
Effacer
</a>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-indigo-100 text-indigo-700">
filtres actifs
</span>
@endif
</div>
</div>
{{-- Filtre par lieu parent --}}
<div class="mt-4 max-w-sm">
<x-lieu-picker
name="lieu_id"
label="Lieu (et ses subdivisions)"
:value="request('lieu_id')"
:display-value="$lieuSelectionne?->nom_long ?? $lieuSelectionne?->nom ?? ''"
placeholder="— Tous les lieux —"
/>
</div>
</form>
</div>
{{-- Tableau --}}
<div class="bg-white shadow rounded-lg overflow-hidden"> <div class="bg-white shadow rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lieu</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lieu</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Code</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Code</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Parent</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Parent</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Coordonnées</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Coordonnées</th>
@@ -43,6 +97,7 @@
{{ $lieu->nom }} {{ $lieu->nom }}
</a> </a>
</td> </td>
<td class="px-6 py-4 text-sm text-gray-500">{{ $lieu->lieuType?->nom ?? '—' }}</td>
<td class="px-6 py-4 text-sm text-gray-500">{{ $lieu->code ?? '—' }}</td> <td class="px-6 py-4 text-sm text-gray-500">{{ $lieu->code ?? '—' }}</td>
<td class="px-6 py-4 text-sm text-gray-500"> <td class="px-6 py-4 text-sm text-gray-500">
@if($lieu->parent) @if($lieu->parent)
@@ -76,7 +131,10 @@
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="5" class="px-6 py-10 text-center text-gray-400">Aucun lieu enregistré.</td> <td colspan="6" class="px-6 py-10 text-center text-gray-400">
@if($hasFilters) Aucun lieu ne correspond aux filtres.
@else Aucun lieu enregistré. @endif
</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
+4
View File
@@ -37,6 +37,10 @@
<dt class="font-medium text-gray-500">Nom complet</dt> <dt class="font-medium text-gray-500">Nom complet</dt>
<dd class="col-span-2 text-gray-900">{{ $lieu->nom_long ?? $lieu->nom }}</dd> <dd class="col-span-2 text-gray-900">{{ $lieu->nom_long ?? $lieu->nom }}</dd>
</div> </div>
<div class="px-6 py-4 grid grid-cols-3 gap-4 text-sm">
<dt class="font-medium text-gray-500">Type</dt>
<dd class="col-span-2 text-gray-900">{{ $lieu->lieuType?->nom ?? '—' }}</dd>
</div>
@if($lieu->code) @if($lieu->code)
<div class="px-6 py-4 grid grid-cols-3 gap-4 text-sm"> <div class="px-6 py-4 grid grid-cols-3 gap-4 text-sm">
<dt class="font-medium text-gray-500">Code</dt> <dt class="font-medium text-gray-500">Code</dt>
@@ -0,0 +1,96 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-800">Notifications</h2>
@if(auth()->user()->unreadNotifications->isNotEmpty())
<form method="POST" action="{{ route('notifications.read-all') }}">
@csrf
<button type="submit"
class="text-sm text-indigo-600 hover:underline">
Tout marquer comme lu
</button>
</form>
@endif
</div>
</x-slot>
<div class="py-8 max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
@if(session('success'))
<div class="mb-4 p-4 bg-green-50 border border-green-200 text-green-800 rounded-md">
{{ session('success') }}
</div>
@endif
<div class="bg-white shadow rounded-lg divide-y divide-gray-100">
@forelse($notifications as $notification)
@php
$data = $notification->data;
$isRejet = ($data['type'] ?? '') === 'rejet';
$isRead = $notification->read_at !== null;
@endphp
<div class="px-6 py-4 flex items-start gap-4 {{ $isRead ? 'opacity-60' : '' }}">
{{-- Icône --}}
<div class="shrink-0 mt-0.5">
@if($isRejet)
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-red-100 text-red-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</span>
@else
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-yellow-100 text-yellow-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</span>
@endif
</div>
{{-- Contenu --}}
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-900">
@if($isRejet)
<strong>{{ $data['rejete_par'] }}</strong> a renvoyé la source
<strong>{{ $data['source_nom'] }}</strong> en cours de saisie.
@else
<strong>{{ $data['soumis_par'] }}</strong> a soumis la source
<strong>{{ $data['source_nom'] }}</strong> pour validation.
@endif
</p>
<div class="mt-1 flex items-center gap-3 text-xs text-gray-400">
<span>{{ $notification->created_at->diffForHumans() }}</span>
@if(!$isRead)
<span class="inline-block w-2 h-2 rounded-full bg-indigo-500"></span>
@endif
</div>
</div>
{{-- Actions --}}
<div class="shrink-0 flex items-center gap-3">
<a href="{{ $data['url'] }}"
class="text-sm text-indigo-600 hover:underline">
Voir
</a>
@if(!$isRead)
<form method="POST" action="{{ route('notifications.read', $notification->id) }}">
@csrf
<button type="submit" class="text-xs text-gray-400 hover:text-gray-600" title="Marquer comme lu"></button>
</form>
@endif
</div>
</div>
@empty
<div class="px-6 py-16 text-center text-gray-400">
<svg class="mx-auto w-10 h-10 mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
<p>Aucune notification</p>
</div>
@endforelse
</div>
@if($notifications->hasPages())
<div class="mt-4">{{ $notifications->links() }}</div>
@endif
</div>
</x-app-layout>
+203
View File
@@ -0,0 +1,203 @@
<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold text-gray-800">Recherche dans les relevés</h2>
</x-slot>
<div class="py-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
{{-- Formulaire de recherche --}}
<div class="bg-white shadow rounded-lg p-6">
<form method="GET" action="{{ route('recherche') }}" class="space-y-4">
{{-- Barre principale --}}
<div class="flex gap-3">
<div class="flex-1 relative">
<div class="absolute inset-y-0 left-3 flex items-center pointer-events-none">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
</div>
<input type="text" name="q" value="{{ request('q') }}"
placeholder="Nom, prénom, lieu, note…"
autofocus
class="block w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-md shadow-sm
text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<button type="submit"
class="px-6 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
Rechercher
</button>
@if(request()->anyFilled(['q', 'source_type_id', 'annee_debut', 'annee_fin']))
<a href="{{ route('recherche') }}"
class="px-4 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50">
Effacer
</a>
@endif
</div>
{{-- Filtres avancés --}}
@php
$hasAdvanced = request()->anyFilled(['source_type_id', 'lieu_id', 'annee_debut', 'annee_fin']);
@endphp
<div x-data="{ open: {{ $hasAdvanced ? 'true' : 'false' }} }">
<button type="button" @click="open = !open"
class="text-sm text-indigo-600 hover:underline flex items-center gap-1">
<span x-text="open ? '▲ Masquer les filtres' : '▼ Filtres avancés'"></span>
@if($hasAdvanced)
<span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs bg-indigo-100 text-indigo-700">
actifs
</span>
@endif
</button>
<div x-show="open" x-cloak class="mt-4 space-y-4">
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Type de source</label>
<select name="source_type_id"
class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value=""> Tous les types </option>
@foreach($sourceTypes as $st)
<option value="{{ $st->id }}" {{ request('source_type_id') == $st->id ? 'selected' : '' }}>
{{ $st->nom }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Année de début</label>
<input type="number" name="annee_debut" value="{{ request('annee_debut') }}"
min="1000" max="2100" placeholder="ex : 1820"
class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Année de fin</label>
<input type="number" name="annee_fin" value="{{ request('annee_fin') }}"
min="1000" max="2100" placeholder="ex : 1830"
class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
</div>
{{-- Filtre par lieu --}}
<div class="max-w-sm">
<x-lieu-picker
name="lieu_id"
label="Lieu (et ses subdivisions)"
:value="request('lieu_id')"
:display-value="$lieuSelectionne?->nom_long ?? $lieuSelectionne?->nom ?? ''"
placeholder="— Tous les lieux —"
/>
@if($lieuSelectionne)
<p class="mt-1 text-xs text-gray-400">
Inclut toutes les subdivisions de {{ $lieuSelectionne->nom_long ?? $lieuSelectionne->nom }}.
</p>
@endif
</div>
</div>
</div>
</form>
</div>
{{-- Résultats --}}
@if($resultats !== null)
<div>
<p class="text-sm text-gray-500 mb-3">
@if($total === 0)
Aucun relevé trouvé.
@else
<strong>{{ number_format($total) }}</strong> relevé{{ $total > 1 ? 's' : '' }} trouvé{{ $total > 1 ? 's' : '' }}
@if(request('q')) pour <em>« {{ request('q') }} »</em> @endif
<a href="{{ route('export.recherche', request()->query()) }}"
class="text-indigo-600 hover:underline">
Exporter en GEDCOM
</a>
@endif
</p>
@if($resultats->isNotEmpty())
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nom</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Prénom</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Source</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($resultats as $releve)
@php
$data = $releve->data;
$dateEvt = $data['date_evenement'] ?? null;
$dateAffichee = is_array($dateEvt)
? ($dateEvt['valeur'] ?? '—') . ($dateEvt['calendrier'] !== 'gregorien' ? ' (' . $dateEvt['calendrier'] . ')' : '')
: ($releve->date_evenement ?? '—');
@endphp
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">
@if(request('q') && $releve->nom)
{!! preg_replace('/(' . preg_quote(request('q'), '/') . ')/i', '<mark class="bg-yellow-100 rounded px-0.5">$1</mark>', e($releve->nom)) !!}
@else
{{ $releve->nom ?? '—' }}
@endif
</td>
<td class="px-4 py-3 text-gray-700">
@if(request('q') && $releve->prenom)
{!! preg_replace('/(' . preg_quote(request('q'), '/') . ')/i', '<mark class="bg-yellow-100 rounded px-0.5">$1</mark>', e($releve->prenom)) !!}
@else
{{ $releve->prenom ?? '—' }}
@endif
</td>
<td class="px-4 py-3 text-gray-600 whitespace-nowrap">
{{ $dateAffichee }}
</td>
<td class="px-4 py-3 text-gray-600">
<a href="{{ route('sources.show', $releve->source) }}"
class="hover:text-indigo-600 hover:underline">
{{ $releve->source->nom }}
</a>
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
{{ $releve->source->sourceType->nom }}
</span>
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('releves.show', $releve) }}"
class="text-indigo-600 hover:underline text-xs">
Voir
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if($resultats->hasPages())
<div class="px-6 py-4 border-t border-gray-200">
{{ $resultats->links() }}
</div>
@endif
</div>
@endif
</div>
@else
{{-- État initial --}}
<div class="text-center py-16 text-gray-400">
<svg class="mx-auto w-12 h-12 mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
<p class="text-sm">Saisissez un nom, prénom, lieu ou tout autre terme pour rechercher dans les relevés.</p>
</div>
@endif
</div>
</x-app-layout>
+100
View File
@@ -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
<div class="space-y-1">
<label for="{{ $inputId }}" class="block text-sm font-medium text-gray-700">
{{ $field->label }}
@if($field->required) <span class="text-red-500">*</span> @endif
</label>
@switch($field->type)
@case(FieldType::Text)
<input type="text" id="{{ $inputId }}" name="{{ $name }}"
value="{{ $oldValue }}"
{{ $field->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)
<textarea id="{{ $inputId }}" name="{{ $name }}" rows="3"
{{ $field->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">{{ $oldValue }}</textarea>
@break
@case(FieldType::Number)
<input type="number" id="{{ $inputId }}" name="{{ $name }}"
value="{{ $oldValue }}" step="any"
{{ $field->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
<div class="flex items-center gap-2 mt-1">
<input type="hidden" name="{{ $name }}" value="0">
<input type="checkbox" id="{{ $inputId }}" name="{{ $name }}" value="1"
{{ $checked ? 'checked' : '' }}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<span class="text-sm text-gray-600">{{ $field->label }}</span>
</div>
@break
@case(FieldType::Select)
<select id="{{ $inputId }}" name="{{ $name }}"
{{ $field->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">
@if(!$field->required) <option value=""> Choisir </option> @endif
@foreach($field->options ?? [] as $opt)
<option value="{{ $opt }}" {{ $oldValue === $opt ? 'selected' : '' }}>{{ $opt }}</option>
@endforeach
</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
<div x-data="{ cal: '{{ $dateCal }}' }" class="flex gap-2">
{{-- Sélecteur de calendrier --}}
<select name="{{ $name }}[calendrier]" x-model="cal"
class="w-40 rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="gregorien">Grégorien</option>
<option value="julien">Julien</option>
<option value="republicain">Républicain</option>
</select>
{{-- Date grégorienne / julienne : input date HTML5 --}}
<input x-show="cal !== 'republicain'"
type="date" name="{{ $name }}[valeur]"
value="{{ $dateCal !== 'republicain' ? $dateVal : '' }}"
{{ $field->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") --}}
<input x-show="cal === 'republicain'" x-cloak
type="text" name="{{ $name }}[valeur]"
value="{{ $dateCal === 'republicain' ? $dateVal : '' }}"
placeholder="ex : 15 Vendémiaire An III"
class="flex-1 rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
@error("data.{$field->name}.valeur")
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
@break
@endswitch
@error("data.{$field->name}")
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
+15
View File
@@ -0,0 +1,15 @@
{{-- $source->sourceType->fields doit être chargé --}}
{{-- $releve : null pour création, instance pour édition --}}
<div class="space-y-6">
@forelse($source->sourceType->fields as $field)
@php
$rawValue = $releve?->data[$field->name] ?? null;
@endphp
@include('releves._field', ['field' => $field, 'value' => $rawValue])
@empty
<p class="text-sm text-gray-400 italic">
Ce type de source n'a aucun champ défini.
<a href="{{ route('admin.source-types.show', $source->sourceType) }}" class="text-indigo-600 hover:underline">Configurer les champs </a>
</p>
@endforelse
</div>
+28
View File
@@ -0,0 +1,28 @@
<x-app-layout>
<x-slot name="header">
<div>
<h2 class="text-xl font-semibold text-gray-800">Nouveau relevé</h2>
<p class="text-sm text-gray-500 mt-0.5">
Source : <a href="{{ route('sources.show', $source) }}" class="text-indigo-600 hover:underline">{{ $source->nom }}</a>
· Type : {{ $source->sourceType->nom }}
</p>
</div>
</x-slot>
<div class="py-8 max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white shadow rounded-lg p-6">
<form method="POST" action="{{ route('sources.releves.store', $source) }}">
@csrf
@include('releves._form', ['releve' => null])
<div class="mt-8 pt-6 border-t border-gray-200 flex items-center gap-4">
<button type="submit"
class="px-5 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
Enregistrer le relevé
</button>
<a href="{{ route('sources.releves.index', $source) }}"
class="text-sm text-gray-500 hover:text-gray-700">Annuler</a>
</div>
</form>
</div>
</div>
</x-app-layout>
+27
View File
@@ -0,0 +1,27 @@
<x-app-layout>
<x-slot name="header">
<div>
<h2 class="text-xl font-semibold text-gray-800">Modifier le relevé #{{ $releve->id }}</h2>
<p class="text-sm text-gray-500 mt-0.5">
Source : <a href="{{ route('sources.show', $source) }}" class="text-indigo-600 hover:underline">{{ $source->nom }}</a>
</p>
</div>
</x-slot>
<div class="py-8 max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white shadow rounded-lg p-6">
<form method="POST" action="{{ route('releves.update', $releve) }}">
@csrf @method('PUT')
@include('releves._form', ['releve' => $releve])
<div class="mt-8 pt-6 border-t border-gray-200 flex items-center gap-4">
<button type="submit"
class="px-5 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
Enregistrer
</button>
<a href="{{ route('releves.show', $releve) }}"
class="text-sm text-gray-500 hover:text-gray-700">Annuler</a>
</div>
</form>
</div>
</div>
</x-app-layout>
+120
View File
@@ -0,0 +1,120 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-800">Relevés {{ $source->nom }}</h2>
<p class="text-sm text-gray-500 mt-0.5">
Type : {{ $source->sourceType->nom }}
@if($source->cote) · Cote : {{ $source->cote }} @endif
</p>
</div>
<div class="flex items-center gap-3">
<a href="{{ route('sources.show', $source) }}" class="text-sm text-indigo-600 hover:underline"> Source</a>
<a href="{{ route('export.source', $source) }}"
class="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50"
title="Télécharger au format GEDCOM 5.5.1">
GEDCOM
</a>
@can('create', [App\Models\Releve::class, $source])
<a href="{{ route('sources.releves.create', $source) }}"
class="px-4 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700">
+ Nouveau relevé
</a>
@endcan
</div>
</div>
</x-slot>
<div class="py-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@if(session('success'))
<div class="mb-4 p-4 bg-green-50 border border-green-200 text-green-800 rounded-md">{{ session('success') }}</div>
@endif
@php
// Colonnes à afficher : les 4 premiers champs du type de source
$colonnes = $source->sourceType->fields->take(5);
@endphp
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
@foreach($colonnes as $col)
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
{{ $col->label }}
</th>
@endforeach
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Saisi par</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse($releves as $releve)
<tr class="hover:bg-gray-50">
@foreach($colonnes as $col)
<td class="px-4 py-3 text-gray-700">
@php $val = $releve->data[$col->name] ?? null; @endphp
@if(is_array($val))
{{ $val['valeur'] ?? '' }}
@if(!empty($val['calendrier']) && $val['calendrier'] !== 'gregorien')
<span class="text-xs text-gray-400">({{ $val['calendrier'] }})</span>
@endif
@elseif(is_bool($val))
{{ $val ? 'Oui' : 'Non' }}
@else
{{ $val ?? '—' }}
@endif
</td>
@endforeach
<td class="px-4 py-3 text-gray-500">{{ $releve->createur?->name ?? '—' }}</td>
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">{{ $releve->created_at->format('d/m/Y') }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap space-x-3">
<a href="{{ route('releves.show', $releve) }}" class="text-indigo-600 hover:underline">Voir</a>
@can('update', $releve)
<a href="{{ route('releves.edit', $releve) }}" class="text-gray-600 hover:text-indigo-600">Modifier</a>
@endcan
@can('delete', $releve)
<form method="POST" action="{{ route('releves.destroy', $releve) }}" class="inline"
x-data @submit.prevent="if(confirm('Supprimer ce relevé ?')) $el.submit()">
@csrf @method('DELETE')
<button type="submit" class="text-red-500 hover:text-red-700">Supprimer</button>
</form>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="{{ $colonnes->count() + 3 }}"
class="px-6 py-10 text-center text-gray-400">
Aucun relevé pour cette source.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Navigation curseur (keyset pagination) --}}
@if($releves->hasPages())
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between text-sm">
<div>
@if($releves->onFirstPage())
<span class="text-gray-400"> Précédent</span>
@else
<a href="{{ $releves->previousPageUrl() }}" class="text-indigo-600 hover:underline"> Précédent</a>
@endif
</div>
<div>
@if($releves->hasMorePages())
<a href="{{ $releves->nextPageUrl() }}" class="text-indigo-600 hover:underline">Suivant </a>
@else
<span class="text-gray-400">Suivant </span>
@endif
</div>
</div>
@endif
</div>
</div>
</x-app-layout>
+74
View File
@@ -0,0 +1,74 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-gray-800">Relevé #{{ $releve->id }}</h2>
<p class="text-sm text-gray-500 mt-0.5">
Source : <a href="{{ route('sources.show', $source) }}" class="text-indigo-600 hover:underline">{{ $source->nom }}</a>
</p>
</div>
<div class="flex items-center gap-3">
@can('update', $releve)
<a href="{{ route('releves.edit', $releve) }}"
class="px-4 py-2 bg-indigo-600 text-white text-sm rounded-md hover:bg-indigo-700">
Modifier
</a>
@endcan
@can('delete', $releve)
<form method="POST" action="{{ route('releves.destroy', $releve) }}"
x-data @submit.prevent="if(confirm('Supprimer ce relevé ?')) $el.submit()">
@csrf @method('DELETE')
<button type="submit"
class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700">
Supprimer
</button>
</form>
@endcan
</div>
</div>
</x-slot>
<div class="py-8 max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
@if(session('success'))
<div class="p-4 bg-green-50 border border-green-200 text-green-800 rounded-md">{{ session('success') }}</div>
@endif
{{-- Champs du relevé --}}
<div class="bg-white shadow rounded-lg divide-y divide-gray-100">
@foreach($source->sourceType->fields as $field)
@php $val = $releve->data[$field->name] ?? null; @endphp
<div class="px-6 py-4 grid grid-cols-3 gap-4 text-sm">
<dt class="font-medium text-gray-500">{{ $field->label }}</dt>
<dd class="col-span-2 text-gray-900">
@if($val === null || $val === '')
<span class="text-gray-400"></span>
@elseif(is_array($val))
{{ $val['valeur'] ?? '—' }}
@if(!empty($val['calendrier']) && $val['calendrier'] !== 'gregorien')
<span class="ml-1 text-xs text-gray-400 capitalize">({{ $val['calendrier'] }})</span>
@endif
@elseif(is_bool($val))
<span class="{{ $val ? 'text-green-700' : 'text-gray-400' }}">
{{ $val ? 'Oui' : 'Non' }}
</span>
@else
{{ $val }}
@endif
</dd>
</div>
@endforeach
</div>
{{-- Méta-données de saisie --}}
<div class="bg-gray-50 rounded-lg px-6 py-4 text-xs text-gray-500 space-y-1">
<p>Saisi par <strong>{{ $releve->createur?->name ?? '?' }}</strong> le {{ $releve->created_at->format('d/m/Y à H:i') }}</p>
@if($releve->updated_at != $releve->created_at)
<p>Modifié par <strong>{{ $releve->modificateur?->name ?? '?' }}</strong> le {{ $releve->updated_at->format('d/m/Y à H:i') }}</p>
@endif
</div>
<div class="flex gap-4 text-sm">
<a href="{{ route('sources.releves.index', $source) }}" class="text-indigo-600 hover:underline"> Liste des relevés</a>
</div>
</div>
</x-app-layout>
+42
View File
@@ -41,6 +41,48 @@
</div> </div>
</div> </div>
{{-- 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
<div>
<x-lieu-picker
name="lieu_id"
label="Lieu couvert"
:value="$lieuIdForm"
:display-value="$lieuDispForm"
placeholder="— Aucun lieu —"
/>
@error('lieu_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Période couverte --}}
<div class="grid grid-cols-2 gap-4">
<div>
<label for="annee_debut" class="block text-sm font-medium text-gray-700">Année de début</label>
<input type="number" id="annee_debut" name="annee_debut"
value="{{ old('annee_debut', $source?->annee_debut) }}"
min="1000" max="2100" placeholder="ex : 1820"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 @error('annee_debut') border-red-500 @enderror">
@error('annee_debut') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="annee_fin" class="block text-sm font-medium text-gray-700">Année de fin</label>
<input type="number" id="annee_fin" name="annee_fin"
value="{{ old('annee_fin', $source?->annee_fin) }}"
min="1000" max="2100" placeholder="ex : 1870"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 @error('annee_fin') border-red-500 @enderror">
@error('annee_fin') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label for="cote" class="block text-sm font-medium text-gray-700">Cote</label> <label for="cote" class="block text-sm font-medium text-gray-700">Cote</label>
+112 -8
View File
@@ -9,16 +9,103 @@
</div> </div>
</x-slot> </x-slot>
<div class="py-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="py-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
@if(session('success')) <div class="mb-4 p-4 bg-green-50 border border-green-200 text-green-800 rounded-md">{{ session('success') }}</div> @endif @if(session('success'))
<div class="p-4 bg-green-50 border border-green-200 text-green-800 rounded-md">{{ session('success') }}</div>
@endif
{{-- Filtres --}}
@php
$hasFilters = request()->anyFilled(['status', 'source_type_id', 'lieu_id', 'annee_debut', 'annee_fin']);
@endphp
<div class="bg-white shadow rounded-lg p-5">
<form method="GET" action="{{ route('sources.index') }}">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{{-- Statut --}}
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Statut</label>
<select name="status"
class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value=""> Tous </option>
@foreach(\App\Enums\SourceStatus::cases() as $s)
<option value="{{ $s->value }}" {{ request('status') === $s->value ? 'selected' : '' }}>
{{ $s->label() }}
</option>
@endforeach
</select>
</div>
{{-- Type de source --}}
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Type de source</label>
<select name="source_type_id"
class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value=""> Tous </option>
@foreach($sourceTypes as $st)
<option value="{{ $st->id }}" {{ request('source_type_id') == $st->id ? 'selected' : '' }}>
{{ $st->nom }}
</option>
@endforeach
</select>
</div>
{{-- Année de début --}}
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Période de</label>
<input type="number" name="annee_debut" value="{{ request('annee_debut') }}"
min="1000" max="2100" placeholder="ex : 1820"
class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
{{-- Année de fin --}}
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Période à</label>
<input type="number" name="annee_fin" value="{{ request('annee_fin') }}"
min="1000" max="2100" placeholder="ex : 1870"
class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
</div>
{{-- Lieu --}}
<div class="mt-4 max-w-sm">
<x-lieu-picker
name="lieu_id"
label="Lieu (et ses subdivisions)"
:value="request('lieu_id')"
:display-value="$lieuSelectionne?->nom_long ?? $lieuSelectionne?->nom ?? ''"
placeholder="— Tous les lieux —"
/>
</div>
<div class="mt-4 flex items-center gap-3">
<button type="submit"
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">
Filtrer
</button>
@if($hasFilters)
<a href="{{ route('sources.index') }}"
class="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50">
Effacer les filtres
</a>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-indigo-100 text-indigo-700">
filtres actifs
</span>
@endif
</div>
</form>
</div>
{{-- Tableau --}}
<div class="bg-white shadow rounded-lg overflow-hidden"> <div class="bg-white shadow rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nom</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nom</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Statut</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Statut</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Lieu</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Période</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Relevés</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Relevés</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Dépôt</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Dépôt</th>
<th class="px-6 py-3"></th> <th class="px-6 py-3"></th>
@@ -34,20 +121,30 @@
'termine' => 'bg-green-100 text-green-700', 'termine' => 'bg-green-100 text-green-700',
]; ];
$color = $statusColors[$source->status->value] ?? 'bg-gray-100 text-gray-600'; $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 @endphp
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-6 py-4 font-medium"> <td class="px-6 py-4 font-medium">
<a href="{{ route('sources.show', $source) }}" class="text-indigo-600 hover:underline">{{ $source->nom }}</a> <a href="{{ route('sources.show', $source) }}" class="text-indigo-600 hover:underline">{{ $source->nom }}</a>
@if($source->cote) <span class="ml-2 text-xs text-gray-400">{{ $source->cote }}</span> @endif @if($source->cote) <span class="ml-2 text-xs text-gray-400">{{ $source->cote }}</span> @endif
</td> </td>
<td class="px-6 py-4 text-sm text-gray-500">{{ $source->sourceType->nom }}</td> <td class="px-6 py-4 text-gray-500">{{ $source->sourceType->nom }}</td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $color }}"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $color }}">
{{ $source->status->label() }} {{ $source->status->label() }}
</span> </span>
</td> </td>
<td class="px-6 py-4 text-sm text-gray-500">{{ $source->releves_count }}</td> <td class="px-6 py-4 text-gray-500 max-w-[180px] truncate" title="{{ $source->lieu?->nom_long ?? $source->lieu?->nom }}">
<td class="px-6 py-4 text-sm text-gray-500">{{ $source->depot?->nom ?? '—' }}</td> {{ $source->lieu?->nom ?? '—' }}
</td>
<td class="px-6 py-4 text-gray-500 whitespace-nowrap">{{ $periode }}</td>
<td class="px-6 py-4 text-gray-500">{{ $source->releves_count }}</td>
<td class="px-6 py-4 text-gray-500">{{ $source->depot?->nom ?? '—' }}</td>
<td class="px-6 py-4 text-right text-sm space-x-3"> <td class="px-6 py-4 text-right text-sm space-x-3">
@can('update', $source) @can('update', $source)
<a href="{{ route('sources.edit', $source) }}" class="text-gray-600 hover:text-indigo-600">Modifier</a> <a href="{{ route('sources.edit', $source) }}" class="text-gray-600 hover:text-indigo-600">Modifier</a>
@@ -55,11 +152,18 @@
</td> </td>
</tr> </tr>
@empty @empty
<tr><td colspan="6" class="px-6 py-10 text-center text-gray-400">Aucune source disponible.</td></tr> <tr>
<td colspan="8" class="px-6 py-10 text-center text-gray-400">
@if($hasFilters) Aucune source ne correspond aux filtres.
@else Aucune source disponible. @endif
</td>
</tr>
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
@if($sources->hasPages()) <div class="px-6 py-4 border-t">{{ $sources->links() }}</div> @endif @if($sources->hasPages())
<div class="px-6 py-4 border-t">{{ $sources->links() }}</div>
@endif
</div> </div>
</div> </div>
</x-app-layout> </x-app-layout>
+11 -2
View File
@@ -135,12 +135,21 @@
<div class="bg-white shadow rounded-lg overflow-hidden"> <div class="bg-white shadow rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between"> <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="font-medium text-gray-900">Relevés ({{ $source->releves->count() }})</h3> <h3 class="font-medium text-gray-900">Relevés ({{ $source->releves->count() }})</h3>
{{-- Lien activé à l'étape 6 --}} @can('create', [App\Models\Releve::class, $source])
<a href="{{ route('sources.releves.create', $source) }}"
class="px-3 py-1.5 bg-indigo-600 text-white text-xs rounded-md hover:bg-indigo-700">
+ Nouveau relevé
</a>
@endcan
</div> </div>
@if($source->releves->isEmpty()) @if($source->releves->isEmpty())
<p class="px-6 py-8 text-center text-gray-400 text-sm">Aucun relevé pour cette source.</p> <p class="px-6 py-8 text-center text-gray-400 text-sm">Aucun relevé pour cette source.</p>
@else @else
<p class="px-6 py-4 text-sm text-gray-500">{{ $source->releves->count() }} relevé(s) enregistré(s).</p> <p class="px-6 py-4 text-sm text-gray-500">
<a href="{{ route('sources.releves.index', $source) }}" class="text-indigo-600 hover:underline">
Voir les {{ $source->releves->count() }} relevé(s)
</a>
</p>
@endif @endif
</div> </div>
+5
View File
@@ -1,11 +1,16 @@
<?php <?php
use App\Http\Controllers\Admin\DepotController; use App\Http\Controllers\Admin\DepotController;
use App\Http\Controllers\Admin\LieuTypeController;
use App\Http\Controllers\Admin\SectionController; use App\Http\Controllers\Admin\SectionController;
use App\Http\Controllers\Admin\SourceTypeController; use App\Http\Controllers\Admin\SourceTypeController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () { Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () {
Route::resource('lieu-types', LieuTypeController::class)
->parameters(['lieu-types' => 'lieuType'])
->except(['show']);
Route::resource('sections', SectionController::class); Route::resource('sections', SectionController::class);
Route::post('sections/{section}/membres', [SectionController::class, 'addMembre'])->name('sections.membres.add'); 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'); Route::delete('sections/{section}/membres/{user}', [SectionController::class, 'removeMembre'])->name('sections.membres.remove');
+18 -1
View File
@@ -1,7 +1,11 @@
<?php <?php
use App\Http\Controllers\ExportController;
use App\Http\Controllers\LieuController; use App\Http\Controllers\LieuController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\RechercheController;
use App\Http\Controllers\ReleveController;
use App\Http\Controllers\SourceController; use App\Http\Controllers\SourceController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -18,12 +22,25 @@ Route::middleware('auth')->group(function () {
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::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::resource('sources', SourceController::class);
Route::post('sources/{source}/membres', [SourceController::class, 'addMembre'])->name('sources.membres.add'); 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::delete('sources/{source}/membres/{user}', [SourceController::class, 'removeMembre'])->name('sources.membres.remove');
Route::post('sources/{source}/transition', [SourceController::class, 'transition'])->name('sources.transition'); 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'; require __DIR__.'/auth.php';