Sprint 2: Live data, CRUD modals, commitments, document upload

Frontend:
- Connect MetroMap to live Inertia props (replace hardcoded demo data)
- Drill-down navigation via router.visit for project-level maps
- Reactive breadcrumb based on map level
- Empty state when no projects exist
- Reusable Modal component with retro styling
- ProjectForm and CommitmentForm with Inertia useForm
- FormInput reusable component (text, date, textarea, select)
- FloatingActions FAB button for creating projects/themes

Backend:
- CommitmentService + CommitmentController (CRUD, mark complete, overdue)
- DocumentService + DocumentController (upload, download, delete)
- MapController now passes users and speerpunten to frontend
- 7 new routes (4 commitment, 3 document)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-01 16:02:38 +02:00
parent 15848b5e96
commit f0aca26642
12 changed files with 1247 additions and 61 deletions

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Models\Commitment;
use App\Services\CommitmentService;
use Illuminate\Http\Request;
class CommitmentController extends Controller
{
public function __construct(
private CommitmentService $commitmentService
) {}
public function store(Request $request)
{
$validated = $request->validate([
'beschrijving' => 'required|string',
'eigenaar_id' => 'required|exists:users,id',
'deadline' => 'required|date',
'project_id' => 'required|exists:projects,id',
'prioriteit' => 'nullable|string',
'bron' => 'nullable|string',
'besluit_id' => 'nullable|exists:besluiten,id',
]);
$this->commitmentService->create($validated);
return back()->with('success', 'Commitment aangemaakt.');
}
public function update(Request $request, Commitment $commitment)
{
$validated = $request->validate([
'beschrijving' => 'sometimes|required|string',
'eigenaar_id' => 'sometimes|required|exists:users,id',
'deadline' => 'sometimes|required|date',
'prioriteit' => 'nullable|string',
'bron' => 'nullable|string',
'status' => 'nullable|string',
]);
$this->commitmentService->update($commitment, $validated);
return back()->with('success', 'Commitment bijgewerkt.');
}
public function complete(Commitment $commitment)
{
$this->commitmentService->markComplete($commitment);
return back()->with('success', 'Commitment afgerond.');
}
public function destroy(Commitment $commitment)
{
$commitment->delete();
return back()->with('success', 'Commitment verwijderd.');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\Document;
use App\Services\DocumentService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class DocumentController extends Controller
{
public function __construct(
private DocumentService $documentService
) {}
public function store(Request $request)
{
$validated = $request->validate([
'titel' => 'required|string|max:255',
'bestand' => 'required|file|max:20480',
'project_id' => 'required|exists:projects,id',
'fase_id' => 'nullable|exists:fases,id',
'type' => 'nullable|string|max:50',
]);
$this->documentService->upload($validated, $request->file('bestand'));
return back()->with('success', 'Document geupload.');
}
public function download(Document $document)
{
abort_unless(
Storage::disk('local')->exists($document->bestandspad),
404,
'Bestand niet gevonden.'
);
return Storage::disk('local')->download(
$document->bestandspad,
$document->titel
);
}
public function destroy(Document $document)
{
$this->documentService->delete($document);
return back()->with('success', 'Document verwijderd.');
}
}

View File

@@ -17,7 +17,9 @@ class MapController extends Controller
$mapData = $this->mapDataService->getStrategyMap();
return Inertia::render('Map/MetroMap', [
'mapData' => $mapData,
'mapData' => $mapData,
'users' => \App\Models\User::select('id', 'name')->get(),
'speerpunten' => \App\Models\Speerpunt::select('id', 'naam', 'thema_id')->with('thema:id,naam')->get(),
]);
}
@@ -26,7 +28,9 @@ class MapController extends Controller
$mapData = $this->mapDataService->getProjectMap($projectId);
return Inertia::render('Map/MetroMap', [
'mapData' => $mapData,
'mapData' => $mapData,
'users' => \App\Models\User::select('id', 'name')->get(),
'speerpunten' => \App\Models\Speerpunt::select('id', 'naam', 'thema_id')->with('thema:id,naam')->get(),
]);
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Services;
use App\Enums\CommitmentStatus;
use App\Models\AuditLog;
use App\Models\Commitment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class CommitmentService
{
/**
* Get all commitments for a project with owner and acties.
*/
public function getForProject(int $projectId): Collection
{
return Commitment::with(['eigenaar', 'acties'])
->where('project_id', $projectId)
->orderBy('deadline')
->get();
}
/**
* Create a new commitment with audit log.
*/
public function create(array $data): Commitment
{
return DB::transaction(function () use ($data) {
$commitment = Commitment::create([
'project_id' => $data['project_id'],
'beschrijving' => $data['beschrijving'],
'eigenaar_id' => $data['eigenaar_id'],
'deadline' => $data['deadline'],
'status' => CommitmentStatus::Open,
'bron' => $data['bron'] ?? null,
'besluit_id' => $data['besluit_id'] ?? null,
]);
$this->audit('created', $commitment);
return $commitment;
});
}
/**
* Update a commitment with audit log.
*/
public function update(Commitment $commitment, array $data): Commitment
{
return DB::transaction(function () use ($commitment, $data) {
$commitment->update(array_filter([
'beschrijving' => $data['beschrijving'] ?? null,
'eigenaar_id' => $data['eigenaar_id'] ?? null,
'deadline' => $data['deadline'] ?? null,
'status' => $data['status'] ?? null,
'bron' => $data['bron'] ?? null,
], fn ($v) => $v !== null));
$this->audit('updated', $commitment);
return $commitment->fresh();
});
}
/**
* Mark a commitment as afgerond with audit log.
*/
public function markComplete(Commitment $commitment): Commitment
{
return DB::transaction(function () use ($commitment) {
$commitment->update(['status' => CommitmentStatus::Afgerond]);
$this->audit('completed', $commitment);
return $commitment->fresh();
});
}
/**
* Get all commitments past their deadline that are not afgerond.
*/
public function getOverdue(): Collection
{
return Commitment::with(['eigenaar', 'project'])
->where('deadline', '<', now()->toDateString())
->where('status', '!=', CommitmentStatus::Afgerond)
->orderBy('deadline')
->get();
}
private function audit(string $action, Commitment $commitment, ?array $extra = null): void
{
AuditLog::create([
'user_id' => Auth::id(),
'action' => "commitment.{$action}",
'entity_type' => 'commitment',
'entity_id' => $commitment->id,
'payload' => $extra,
]);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\Document;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class DocumentService
{
/**
* Get all documents for a project with auteur.
*/
public function getForProject(int $projectId): Collection
{
return Document::with('auteur')
->where('project_id', $projectId)
->latest()
->get();
}
/**
* Store a file and create the Document record.
*/
public function upload(array $data, UploadedFile $file): Document
{
return DB::transaction(function () use ($data, $file) {
$projectId = $data['project_id'];
$path = $file->store("documents/{$projectId}", 'local');
$document = Document::create([
'project_id' => $projectId,
'fase_id' => $data['fase_id'] ?? null,
'titel' => $data['titel'],
'type' => $data['type'] ?? $file->getClientOriginalExtension(),
'bestandspad' => $path,
'versie' => 1,
'auteur_id' => Auth::id(),
]);
$this->audit('uploaded', $document);
return $document;
});
}
/**
* Delete the file from storage and soft-delete the Document record.
*/
public function delete(Document $document): void
{
DB::transaction(function () use ($document) {
if ($document->bestandspad && Storage::disk('local')->exists($document->bestandspad)) {
Storage::disk('local')->delete($document->bestandspad);
}
$this->audit('deleted', $document);
$document->delete();
});
}
private function audit(string $action, Document $document, ?array $extra = null): void
{
AuditLog::create([
'user_id' => Auth::id(),
'action' => "document.{$action}",
'entity_type' => 'document',
'entity_id' => $document->id,
'payload' => $extra,
]);
}
}