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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 17:17:53 +02:00
parent 7609d35287
commit d064f8d28e
54 changed files with 2861 additions and 116 deletions
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreLieuTypeRequest;
use App\Models\LieuType;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class LieuTypeController extends Controller
{
public function index(): View
{
$lieuTypes = LieuType::withCount('lieux')->orderBy('ordre')->get();
return view('admin.lieu-types.index', compact('lieuTypes'));
}
public function create(): View
{
return view('admin.lieu-types.create');
}
public function store(StoreLieuTypeRequest $request): RedirectResponse
{
LieuType::create($request->validated());
return redirect()->route('admin.lieu-types.index')->with('success', 'Type de lieu créé.');
}
public function edit(LieuType $lieuType): View
{
return view('admin.lieu-types.edit', compact('lieuType'));
}
public function update(StoreLieuTypeRequest $request, LieuType $lieuType): RedirectResponse
{
$lieuType->update($request->validated());
return redirect()->route('admin.lieu-types.index')->with('success', 'Type de lieu mis à jour.');
}
public function destroy(LieuType $lieuType): RedirectResponse
{
if ($lieuType->lieux()->exists()) {
return back()->with('error', 'Impossible de supprimer un type utilisé par des lieux.');
}
$lieuType->delete();
return redirect()->route('admin.lieu-types.index')->with('success', 'Type de lieu supprimé.');
}
}
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreSectionRequest;
use App\Http\Requests\Admin\UpdateSectionRequest;
use App\Models\Lieu;
use App\Models\Section;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
@@ -22,8 +21,7 @@ class SectionController extends Controller
public function create(): View
{
$lieux = Lieu::orderBy('nom_long')->get(['id', 'nom_long', 'nom']);
return view('admin.sections.create', compact('lieux'));
return view('admin.sections.create');
}
public function store(StoreSectionRequest $request): RedirectResponse
@@ -42,8 +40,8 @@ class SectionController extends Controller
public function edit(Section $section): View
{
$lieux = Lieu::orderBy('nom_long')->get(['id', 'nom_long', 'nom']);
return view('admin.sections.edit', compact('section', 'lieux'));
$section->load('lieu');
return view('admin.sections.edit', compact('section'));
}
public function update(UpdateSectionRequest $request, Section $section): RedirectResponse
+1 -1
View File
@@ -4,5 +4,5 @@ namespace App\Http\Controllers;
abstract class Controller
{
//
use \Illuminate\Foundation\Auth\Access\AuthorizesRequests;
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SourceStatus;
use App\Models\Releve;
use App\Models\Source;
use App\Services\GedcomExportService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class ExportController extends Controller
{
public function __construct(
private readonly GedcomExportService $gedcom,
) {}
/** Export de tous les relevés d'une source */
public function source(Source $source): Response
{
$this->authorize('view', $source);
$gedcomContent = $this->gedcom->exportSource($source);
$filename = $this->sanitizeFilename($source->nom) . '.ged';
return response($gedcomContent, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
]);
}
/** Export depuis les résultats de recherche (avec les mêmes filtres) */
public function recherche(Request $request): Response
{
$user = auth()->user();
$query = Releve::with(['source.sourceType', 'createur'])
->whereHas('source', function ($q) use ($user, $request) {
if (! $user->isSectionManager()) {
$assignedIds = $user->sourcesAssignees()->pluck('sources.id');
$q->where(function ($sq) use ($assignedIds) {
$sq->where('status', SourceStatus::Termine)
->orWhereIn('id', $assignedIds);
});
}
if ($request->filled('source_type_id')) {
$q->where('source_type_id', $request->integer('source_type_id'));
}
}); // $request déjà dans le use()
if ($request->filled('q')) {
$q = trim($request->get('q'));
$query->where(function ($wq) use ($q) {
$wq->where('nom', 'ilike', "%{$q}%")
->orWhere('prenom','ilike', "%{$q}%")
->orWhere('date_evenement', 'ilike', "%{$q}%")
->orWhereRaw(
"to_tsvector('french', data::text) @@ plainto_tsquery('french', ?)",
[$q]
);
});
}
if ($request->filled('lieu_id')) {
$rows = DB::select("
WITH RECURSIVE descendants AS (
SELECT id, nom FROM lieux WHERE id = ?
UNION ALL
SELECT l.id, l.nom FROM lieux l
INNER JOIN descendants d ON l.lieu_parent_id = d.id
)
SELECT DISTINCT nom FROM descendants WHERE nom IS NOT NULL
", [$request->integer('lieu_id')]);
$noms = collect($rows)->pluck('nom')->filter();
if ($noms->isNotEmpty()) {
$pattern = $noms->map(fn ($n) => preg_quote($n, '/'))->join('|');
$query->whereRaw("data::text ~* ?", [$pattern]);
}
}
if ($request->filled('annee_debut')) {
$query->whereRaw('date_evenement >= ?', [$request->integer('annee_debut') . '-01-01']);
}
if ($request->filled('annee_fin')) {
$query->whereRaw('date_evenement <= ?', [$request->integer('annee_fin') . '-12-31']);
}
$releves = $query->orderByRaw('nom ASC NULLS LAST')->get();
if ($releves->isEmpty()) {
return back()->with('error', 'Aucun relevé à exporter.');
}
$titre = $request->filled('q') ? 'Recherche_' . $request->get('q') : 'Export';
$gedcomContent = $this->gedcom->exportReleves($releves, $titre);
$filename = $this->sanitizeFilename($titre) . '.ged';
return response($gedcomContent, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
]);
}
private function sanitizeFilename(string $name): string
{
$name = iconv('UTF-8', 'ASCII//TRANSLIT', $name) ?: $name;
return preg_replace('/[^a-zA-Z0-9_-]/', '_', $name);
}
}
+76 -19
View File
@@ -5,30 +5,77 @@ namespace App\Http\Controllers;
use App\Http\Requests\StoreLieuRequest;
use App\Http\Requests\UpdateLieuRequest;
use App\Models\Lieu;
use App\Models\LieuType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class LieuController extends Controller
{
public function index(): View
public function search(Request $request): JsonResponse
{
$q = trim($request->get('q', ''));
$lieux = Lieu::with('lieuType')
->where(function ($query) use ($q) {
$query->where('nom_long', 'ilike', "%{$q}%")
->orWhere('nom', 'ilike', "%{$q}%")
->orWhere('code', 'ilike', "%{$q}%");
})
->orderBy('nom_long')
->limit(25)
->get();
return response()->json($lieux->map(fn ($l) => [
'id' => $l->id,
'nom_long' => $l->nom_long ?? $l->nom,
'code' => $l->code,
'type' => $l->lieuType?->nom,
]));
}
public function index(Request $request): View
{
$this->authorize('viewAny', Lieu::class);
// Arbre complet trié par nom_long pour l'affichage
$lieux = Lieu::with('parent')
->orderBy('nom_long')
->paginate(50);
$query = Lieu::with(['parent', 'lieuType'])->orderBy('nom_long');
return view('lieux.index', compact('lieux'));
if ($request->filled('lieu_type_id')) {
$query->where('lieu_type_id', $request->integer('lieu_type_id'));
}
if ($request->filled('q')) {
$q = trim($request->get('q'));
$query->where(function ($wq) use ($q) {
$wq->where('nom_long', 'ilike', "%{$q}%")
->orWhere('nom', 'ilike', "%{$q}%")
->orWhere('code', 'ilike', "%{$q}%");
});
}
if ($request->filled('lieu_id')) {
$ids = $this->getDescendantAndSelfIds($request->integer('lieu_id'));
$query->whereIn('id', $ids);
}
$lieuTypes = LieuType::orderBy('ordre')->get(['id', 'nom']);
$lieuSelectionne = $request->filled('lieu_id')
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
: null;
$lieux = $query->paginate(50)->withQueryString();
return view('lieux.index', compact('lieux', 'lieuTypes', 'lieuSelectionne'));
}
public function create(): View
{
$this->authorize('create', Lieu::class);
$parents = Lieu::orderBy('nom_long')->get(['id', 'nom_long']);
$lieuTypes = LieuType::orderBy('ordre')->get(['id', 'nom']);
return view('lieux.create', compact('parents'));
return view('lieux.create', compact('lieuTypes'));
}
public function store(StoreLieuRequest $request): RedirectResponse
@@ -43,7 +90,7 @@ class LieuController extends Controller
{
$this->authorize('view', $lieu);
$lieu->load('parent', 'enfants');
$lieu->load('parent', 'enfants', 'lieuType');
return view('lieux.show', compact('lieu'));
}
@@ -52,20 +99,15 @@ class LieuController extends Controller
{
$this->authorize('update', $lieu);
// Exclure le lieu lui-même et ses descendants pour éviter les cycles
$descendants = $this->getDescendantIds($lieu);
$parents = Lieu::whereNotIn('id', [...$descendants, $lieu->id])
->orderBy('nom_long')
->get(['id', 'nom_long']);
$lieu->load('parent', 'lieuType');
$lieuTypes = LieuType::orderBy('ordre')->get(['id', 'nom']);
return view('lieux.edit', compact('lieu', 'parents'));
return view('lieux.edit', compact('lieu', 'lieuTypes'));
}
public function update(UpdateLieuRequest $request, Lieu $lieu): RedirectResponse
{
$lieu->update($request->validated());
// Recalculer nom_long des enfants en cascade
$this->recalculerEnfants($lieu);
return redirect()->route('lieux.show', $lieu)
@@ -86,12 +128,27 @@ class LieuController extends Controller
->with('success', 'Lieu supprimé.');
}
private function getDescendantAndSelfIds(int $lieuId): array
{
$rows = DB::select("
WITH RECURSIVE descendants AS (
SELECT id FROM lieux WHERE id = ?
UNION ALL
SELECT l.id FROM lieux l
INNER JOIN descendants d ON l.lieu_parent_id = d.id
)
SELECT id FROM descendants
", [$lieuId]);
return collect($rows)->pluck('id')->toArray();
}
private function getDescendantIds(Lieu $lieu): array
{
$ids = [];
foreach ($lieu->enfants as $enfant) {
$ids[] = $enfant->id;
$ids = array_merge($ids, $this->getDescendantIds($enfant));
$ids = array_merge($ids, $this->getDescendantIds($enfant));
}
return $ids;
}
@@ -100,7 +157,7 @@ class LieuController extends Controller
{
$lieu->load('enfants');
foreach ($lieu->enfants as $enfant) {
$enfant->update([]); // déclenche le booted() hook qui recalcule nom_long
$enfant->update([]);
$this->recalculerEnfants($enfant);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$notifications = $request->user()
->notifications()
->paginate(25);
return view('notifications.index', compact('notifications'));
}
public function markAsRead(Request $request, string $id): RedirectResponse
{
$request->user()
->notifications()
->where('id', $id)
->first()
?->markAsRead();
return back();
}
public function markAllAsRead(Request $request): RedirectResponse
{
$request->user()->unreadNotifications->markAsRead();
return back()->with('success', 'Toutes les notifications ont été marquées comme lues.');
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SourceStatus;
use App\Models\Lieu;
use App\Models\Releve;
use App\Models\SourceType;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class RechercheController extends Controller
{
public function index(Request $request): View
{
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
$resultats = null;
$total = null;
// Charger le lieu sélectionné pour pré-remplir le picker
$lieuSelectionne = $request->filled('lieu_id')
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
: null;
if ($request->anyFilled(['q', 'source_type_id', 'lieu_id', 'annee_debut', 'annee_fin'])) {
[$resultats, $total] = $this->search($request);
}
return view('recherche.index', compact('sourceTypes', 'resultats', 'total', 'lieuSelectionne'));
}
private function search(Request $request): array
{
$user = auth()->user();
$query = Releve::with(['source.sourceType', 'source.depot', 'createur'])
->whereHas('source', function ($q) use ($user, $request) {
if (! $user->isSectionManager()) {
$assignedIds = $user->sourcesAssignees()->pluck('sources.id');
$q->where(function ($sq) use ($assignedIds) {
$sq->where('status', SourceStatus::Termine)
->orWhereIn('id', $assignedIds);
});
}
if ($request->filled('source_type_id')) {
$q->where('source_type_id', $request->integer('source_type_id'));
}
});
// ── Recherche textuelle ──────────────────────────────────────────────
if ($request->filled('q')) {
$q = trim($request->get('q'));
$query->where(function ($wq) use ($q) {
$wq->where('nom', 'ilike', "%{$q}%")
->orWhere('prenom','ilike', "%{$q}%")
->orWhere('date_evenement', 'ilike', "%{$q}%")
->orWhereRaw(
"to_tsvector('french', data::text) @@ plainto_tsquery('french', ?)",
[$q]
);
});
}
// ── Filtre par lieu (+ descendants via CTE récursive) ────────────────
if ($request->filled('lieu_id')) {
$lieuNoms = $this->getLieuNoms($request->integer('lieu_id'));
if ($lieuNoms->isNotEmpty()) {
// Recherche regex case-insensitive dans le JSONB text
$pattern = $lieuNoms
->map(fn ($n) => preg_quote($n, '/'))
->join('|');
$query->whereRaw("data::text ~* ?", [$pattern]);
}
}
// ── Filtre par plage d'années ────────────────────────────────────────
if ($request->filled('annee_debut')) {
$query->whereRaw("date_evenement >= ?", [$request->integer('annee_debut') . '-01-01']);
}
if ($request->filled('annee_fin')) {
$query->whereRaw("date_evenement <= ?", [$request->integer('annee_fin') . '-12-31']);
}
// ── Tri + pagination ────────────────────────────────────────────────
$total = $query->count();
$resultats = $query
->orderByRaw('nom ASC NULLS LAST')
->orderByRaw('date_evenement ASC NULLS LAST')
->paginate(25)
->withQueryString();
return [$resultats, $total];
}
/**
* Retourne les noms du lieu et de tous ses descendants via CTE récursive PostgreSQL.
*/
private function getLieuNoms(int $lieuId): \Illuminate\Support\Collection
{
$rows = DB::select("
WITH RECURSIVE descendants AS (
SELECT id, nom
FROM lieux
WHERE id = ?
UNION ALL
SELECT l.id, l.nom
FROM lieux l
INNER JOIN descendants d ON l.lieu_parent_id = d.id
)
SELECT DISTINCT nom FROM descendants WHERE nom IS NOT NULL
", [$lieuId]);
return collect($rows)->pluck('nom')->filter();
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers;
use App\Enums\FieldType;
use App\Http\Requests\StoreReleveRequest;
use App\Http\Requests\UpdateReleveRequest;
use App\Models\Releve;
use App\Models\Source;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class ReleveController extends Controller
{
public function index(Source $source): View
{
$this->authorize('viewAny', [Releve::class, $source]);
$source->load('sourceType.fields');
// Keyset pagination sur id (évite le COUNT sur des millions de lignes)
$releves = $source->releves()
->orderBy('id')
->cursorPaginate(25);
return view('releves.index', compact('source', 'releves'));
}
public function create(Source $source): View
{
$this->authorize('create', [Releve::class, $source]);
$source->load('sourceType.fields');
return view('releves.create', compact('source'));
}
public function store(StoreReleveRequest $request, Source $source): RedirectResponse
{
$data = $this->buildData($request->validated()['data'] ?? [], $source);
$source->releves()->create([
'data' => $data,
'created_by' => $request->user()->id,
'updated_by' => $request->user()->id,
]);
return redirect()->route('sources.releves.index', $source)
->with('success', 'Relevé ajouté.');
}
public function show(Source $source, Releve $releve): View
{
$this->authorize('view', $releve);
$source->load('sourceType.fields');
$releve->load('createur', 'modificateur');
return view('releves.show', compact('source', 'releve'));
}
public function edit(Source $source, Releve $releve): View
{
$this->authorize('update', $releve);
$source->load('sourceType.fields');
return view('releves.edit', compact('source', 'releve'));
}
public function update(UpdateReleveRequest $request, Source $source, Releve $releve): RedirectResponse
{
$data = $this->buildData($request->validated()['data'] ?? [], $source);
$releve->update([
'data' => $data,
'updated_by' => $request->user()->id,
]);
return redirect()->route('sources.releves.show', [$source, $releve])
->with('success', 'Relevé mis à jour.');
}
public function destroy(Source $source, Releve $releve): RedirectResponse
{
$this->authorize('delete', $releve);
$releve->delete();
return redirect()->route('sources.releves.index', $source)
->with('success', 'Relevé supprimé.');
}
// Normalise les données POST en structure JSONB propre
private function buildData(array $raw, Source $source): array
{
$data = [];
foreach ($source->sourceType->fields as $field) {
$value = $raw[$field->name] ?? null;
$data[$field->name] = match ($field->type) {
FieldType::Boolean => (bool) ($value ?? false),
FieldType::Number => $value !== null && $value !== '' ? (float) $value : null,
FieldType::Date => [
'valeur' => $value['valeur'] ?? null,
'calendrier' => $value['calendrier'] ?? 'gregorien',
],
default => $value === '' ? null : $value,
};
}
return $data;
}
}
+76 -5
View File
@@ -6,26 +6,29 @@ use App\Enums\SourceStatus;
use App\Http\Requests\StoreSourceRequest;
use App\Http\Requests\UpdateSourceRequest;
use App\Models\Depot;
use App\Models\Lieu;
use App\Models\Source;
use App\Models\SourceType;
use App\Models\User;
use App\Notifications\SourceAValiderNotification;
use App\Notifications\SourceRejeteeNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class SourceController extends Controller
{
public function index(): View
public function index(Request $request): View
{
$this->authorize('viewAny', Source::class);
$user = auth()->user();
$query = Source::with(['sourceType', 'depot'])
$query = Source::with(['sourceType', 'depot', 'lieu'])
->withCount('releves');
if (! $user->isSectionManager()) {
// Membre : sources terminées + sources assignées
$assignedIds = $user->sourcesAssignees()->pluck('sources.id');
$query->where(function ($q) use ($assignedIds) {
$q->where('status', SourceStatus::Termine)
@@ -33,9 +36,56 @@ class SourceController extends Controller
});
}
$sources = $query->orderBy('nom')->paginate(25);
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
return view('sources.index', compact('sources'));
if ($request->filled('source_type_id')) {
$query->where('source_type_id', $request->integer('source_type_id'));
}
if ($request->filled('lieu_id')) {
$lieuIds = $this->getLieuDescendantIds($request->integer('lieu_id'));
$query->whereIn('lieu_id', $lieuIds);
}
if ($request->filled('annee_debut')) {
$annee = $request->integer('annee_debut');
$query->where(function ($q) use ($annee) {
$q->whereNull('annee_fin')->orWhere('annee_fin', '>=', $annee);
});
}
if ($request->filled('annee_fin')) {
$annee = $request->integer('annee_fin');
$query->where(function ($q) use ($annee) {
$q->whereNull('annee_debut')->orWhere('annee_debut', '<=', $annee);
});
}
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
$lieuSelectionne = $request->filled('lieu_id')
? Lieu::find($request->integer('lieu_id'), ['id', 'nom', 'nom_long'])
: null;
$sources = $query->orderBy('nom')->paginate(25)->withQueryString();
return view('sources.index', compact('sources', 'sourceTypes', 'lieuSelectionne'));
}
private function getLieuDescendantIds(int $lieuId): array
{
$rows = DB::select("
WITH RECURSIVE descendants AS (
SELECT id FROM lieux WHERE id = ?
UNION ALL
SELECT l.id FROM lieux l
INNER JOIN descendants d ON l.lieu_parent_id = d.id
)
SELECT id FROM descendants
", [$lieuId]);
return collect($rows)->pluck('id')->toArray();
}
public function create(): View
@@ -71,6 +121,7 @@ class SourceController extends Controller
{
$this->authorize('update', $source);
$source->loadMissing('lieu');
$sourceTypes = SourceType::orderBy('nom')->get(['id', 'nom']);
$depots = Depot::orderBy('nom')->get(['id', 'nom']);
@@ -136,8 +187,28 @@ class SourceController extends Controller
return back()->with('error', 'Transition non autorisée.');
}
$previousStatus = $source->status;
$source->update(['status' => $newStatus]);
$user = auth()->user();
if ($newStatus === SourceStatus::AValider) {
// Notifier admins + responsables de section
$source->load('sourceType', 'depot');
User::whereIn('role', ['admin', 'section_manager'])
->where('id', '!=', $user->id)
->get()
->each(fn ($u) => $u->notify(new SourceAValiderNotification($source, $user)));
}
if ($newStatus === SourceStatus::EnCours && $previousStatus === SourceStatus::AValider) {
// Rejet : notifier les membres assignés
$source->membres()
->where('users.id', '!=', $user->id)
->get()
->each(fn ($m) => $m->notify(new SourceRejeteeNotification($source, $user)));
}
return back()->with('success', 'Statut mis à jour : ' . $newStatus->label());
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreLieuTypeRequest extends FormRequest
{
public function authorize(): bool { return $this->user()->isAdmin(); }
public function rules(): array
{
$ignore = $this->route('lieuType')?->id;
return [
'nom' => ['required', 'string', 'max:100', Rule::unique('lieu_types', 'nom')->ignore($ignore)],
'ordre' => ['required', 'integer', 'min:0', 'max:999'],
];
}
}
+1
View File
@@ -15,6 +15,7 @@ class StoreLieuRequest extends FormRequest
{
return [
'nom' => ['required', 'string', 'max:255'],
'lieu_type_id' => ['required', 'integer', 'exists:lieu_types,id'],
'code' => ['nullable', 'string', 'max:20'],
'lieu_parent_id'=> ['nullable', 'integer', 'exists:lieux,id'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests;
use App\Enums\CalendarType;
use App\Enums\FieldType;
use App\Models\Source;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
class StoreReleveRequest extends FormRequest
{
public function authorize(): bool
{
$source = $this->route('source');
return $this->user()->can('create', [app(\App\Models\Releve::class), $source]);
}
public function rules(): array
{
/** @var Source $source */
$source = $this->route('source');
$source->loadMissing('sourceType.fields');
$rules = [];
foreach ($source->sourceType->fields as $field) {
$base = "data.{$field->name}";
switch ($field->type) {
case FieldType::Date:
$rules["{$base}.valeur"] = [$field->required ? 'required' : 'nullable', 'string', 'max:50'];
$rules["{$base}.calendrier"] = ['required', new Enum(CalendarType::class)];
break;
case FieldType::Boolean:
$rules[$base] = ['nullable', 'boolean'];
break;
case FieldType::Number:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'numeric'];
break;
case FieldType::Select:
$options = $field->options ?? [];
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)];
break;
default: // text, textarea
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
}
}
return $rules;
}
}
+3
View File
@@ -18,6 +18,9 @@ class StoreSourceRequest extends FormRequest
'description' => ['nullable', 'string'],
'source_type_id' => ['required', 'integer', 'exists:source_types,id'],
'depot_id' => ['nullable', 'integer', 'exists:depots,id'],
'lieu_id' => ['nullable', 'integer', 'exists:lieux,id'],
'annee_debut' => ['nullable', 'integer', 'min:1000', 'max:2100'],
'annee_fin' => ['nullable', 'integer', 'min:1000', 'max:2100', 'gte:annee_debut'],
'cote' => ['nullable', 'string', 'max:255'],
'auteur' => ['nullable', 'string', 'max:255'],
];
+1
View File
@@ -17,6 +17,7 @@ class UpdateLieuRequest extends FormRequest
return [
'nom' => ['required', 'string', 'max:255'],
'lieu_type_id' => ['required', 'integer', 'exists:lieu_types,id'],
'code' => ['nullable', 'string', 'max:20'],
// Interdit de se choisir soi-même ou un descendant comme parent
'lieu_parent_id'=> ['nullable', 'integer', 'exists:lieux,id', "not_in:{$lieuId}"],
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests;
use App\Enums\CalendarType;
use App\Enums\FieldType;
use App\Models\Source;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
class UpdateReleveRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('releve'));
}
public function rules(): array
{
/** @var \App\Models\Releve $releve */
$releve = $this->route('releve');
$releve->source->loadMissing('sourceType.fields');
$rules = [];
foreach ($releve->source->sourceType->fields as $field) {
$base = "data.{$field->name}";
switch ($field->type) {
case FieldType::Date:
$rules["{$base}.valeur"] = [$field->required ? 'required' : 'nullable', 'string', 'max:50'];
$rules["{$base}.calendrier"] = ['required', new Enum(CalendarType::class)];
break;
case FieldType::Boolean:
$rules[$base] = ['nullable', 'boolean'];
break;
case FieldType::Number:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'numeric'];
break;
case FieldType::Select:
$options = $field->options ?? [];
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'in:' . implode(',', $options)];
break;
default:
$rules[$base] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
}
}
return $rules;
}
}
@@ -18,6 +18,9 @@ class UpdateSourceRequest extends FormRequest
'description' => ['nullable', 'string'],
'source_type_id' => ['required', 'integer', 'exists:source_types,id'],
'depot_id' => ['nullable', 'integer', 'exists:depots,id'],
'lieu_id' => ['nullable', 'integer', 'exists:lieux,id'],
'annee_debut' => ['nullable', 'integer', 'min:1000', 'max:2100'],
'annee_fin' => ['nullable', 'integer', 'min:1000', 'max:2100', 'gte:annee_debut'],
'cote' => ['nullable', 'string', 'max:255'],
'auteur' => ['nullable', 'string', 'max:255'],
];