Sprint 1: Auth, metro map canvas, services, and retro UI
Authentication: - Laravel Fortify + Sanctum with Inertia views - RBAC middleware (admin, project_owner, team_member, viewer) - Retro terminal-styled login/register/forgot-password pages Metro Map (core UI): - D3.js zoomable SVG canvas with metro line rendering - Station nodes with glow-on-hover, status coloring, tooltips - Breadcrumb navigation for multi-level drill-down - Node preview panel with zoom-in action - C64-style CLI bar with blinking cursor at bottom Backend services: - ProjectService (CRUD, phase transitions, park/stop, audit logging) - ThemaService (CRUD with audit) - MapDataService (strategy map L1, project map L2) - Thin controllers: MapController, ProjectController, ThemaController - 32 routes total (auth + app + API) Style foundation: - Retro-futurism theme: VT323, Press Start 2P, IBM Plex Mono fonts - Dark palette with cyan/orange/green/purple neon accents - Comprehensive seed data (4 themes, 12 projects, commitments, deps) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
50
app/Actions/Fortify/CreateNewUser.php
Normal file
50
app/Actions/Fortify/CreateNewUser.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class),
|
||||
],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
])->validate();
|
||||
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
|
||||
// Assign default 'viewer' role
|
||||
$viewerRole = Role::where('naam', 'viewer')->first();
|
||||
if ($viewerRole) {
|
||||
$user->roles()->attach($viewerRole);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
19
app/Actions/Fortify/PasswordValidationRules.php
Normal file
19
app/Actions/Fortify/PasswordValidationRules.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
32
app/Actions/Fortify/ResetUserPassword.php
Normal file
32
app/Actions/Fortify/ResetUserPassword.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
31
app/Actions/Fortify/UpdateUserPassword.php
Normal file
31
app/Actions/Fortify/UpdateUserPassword.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string', 'current_password:web'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
42
app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
42
app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
'functie' => ['nullable', 'string', 'max:255'],
|
||||
'afdeling' => ['nullable', 'string', 'max:255'],
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'functie' => $input['functie'] ?? $user->functie,
|
||||
'afdeling' => $input['afdeling'] ?? $user->afdeling,
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/MapController.php
Normal file
42
app/Http/Controllers/MapController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\MapDataService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class MapController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private MapDataService $mapDataService
|
||||
) {}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$mapData = $this->mapDataService->getStrategyMap();
|
||||
|
||||
return Inertia::render('Map/MetroMap', [
|
||||
'mapData' => $mapData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function project(int $projectId)
|
||||
{
|
||||
$mapData = $this->mapDataService->getProjectMap($projectId);
|
||||
|
||||
return Inertia::render('Map/MetroMap', [
|
||||
'mapData' => $mapData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function apiStrategy(Request $request)
|
||||
{
|
||||
return response()->json($this->mapDataService->getStrategyMap());
|
||||
}
|
||||
|
||||
public function apiProject(int $projectId)
|
||||
{
|
||||
return response()->json($this->mapDataService->getProjectMap($projectId));
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/ProjectController.php
Normal file
91
app/Http/Controllers/ProjectController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\ProjectStatus;
|
||||
use App\Models\Project;
|
||||
use App\Services\ProjectService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ProjectService $projectService
|
||||
) {}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'naam' => 'required|string|max:255',
|
||||
'beschrijving' => 'nullable|string',
|
||||
'speerpunt_id' => 'nullable|exists:speerpunten,id',
|
||||
'prioriteit' => 'nullable|string',
|
||||
'startdatum' => 'nullable|date',
|
||||
'streef_einddatum' => 'nullable|date|after:startdatum',
|
||||
]);
|
||||
|
||||
$project = $this->projectService->create($validated);
|
||||
|
||||
return back()->with('success', "Project '{$project->naam}' aangemaakt.");
|
||||
}
|
||||
|
||||
public function show(Project $project)
|
||||
{
|
||||
$project = $this->projectService->getWithDetails($project->id);
|
||||
|
||||
return Inertia::render('Project/Show', [
|
||||
'project' => $project,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Project $project)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'naam' => 'sometimes|string|max:255',
|
||||
'beschrijving' => 'nullable|string',
|
||||
'prioriteit' => 'nullable|string',
|
||||
'speerpunt_id' => 'nullable|exists:speerpunten,id',
|
||||
'streef_einddatum' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$this->projectService->update($project, $validated);
|
||||
|
||||
return back()->with('success', 'Project bijgewerkt.');
|
||||
}
|
||||
|
||||
public function transition(Request $request, Project $project)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|string',
|
||||
]);
|
||||
|
||||
$newStatus = ProjectStatus::from($validated['status']);
|
||||
$this->projectService->transitionPhase($project, $newStatus);
|
||||
|
||||
return back()->with('success', 'Projectfase bijgewerkt.');
|
||||
}
|
||||
|
||||
public function park(Request $request, Project $project)
|
||||
{
|
||||
$reason = $request->input('reason', '');
|
||||
$this->projectService->park($project, $reason);
|
||||
|
||||
return back()->with('success', 'Project geparkeerd.');
|
||||
}
|
||||
|
||||
public function stop(Request $request, Project $project)
|
||||
{
|
||||
$reason = $request->input('reason', '');
|
||||
$this->projectService->stop($project, $reason);
|
||||
|
||||
return back()->with('success', 'Project gestopt.');
|
||||
}
|
||||
|
||||
public function destroy(Project $project)
|
||||
{
|
||||
$project->delete();
|
||||
|
||||
return redirect('/map')->with('success', 'Project verwijderd.');
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/ThemaController.php
Normal file
52
app/Http/Controllers/ThemaController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Thema;
|
||||
use App\Services\ThemaService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ThemaController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ThemaService $themaService
|
||||
) {}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return Inertia::render('Thema/Index', [
|
||||
'themas' => $this->themaService->getAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'naam' => 'required|string|max:255',
|
||||
'beschrijving' => 'nullable|string',
|
||||
'prioriteit' => 'nullable|string',
|
||||
'periode_start' => 'nullable|date',
|
||||
'periode_eind' => 'nullable|date|after:periode_start',
|
||||
]);
|
||||
|
||||
$this->themaService->create($validated);
|
||||
|
||||
return back()->with('success', 'Thema aangemaakt.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Thema $thema)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'naam' => 'sometimes|string|max:255',
|
||||
'beschrijving' => 'nullable|string',
|
||||
'prioriteit' => 'nullable|string',
|
||||
'periode_start' => 'nullable|date',
|
||||
'periode_eind' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$this->themaService->update($thema, $validated);
|
||||
|
||||
return back()->with('success', 'Thema bijgewerkt.');
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/CheckRole.php
Normal file
19
app/Http/Middleware/CheckRole.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CheckRole
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string ...$roles): Response
|
||||
{
|
||||
if (! $request->user() || ! $request->user()->roles()->whereIn('naam', $roles)->exists()) {
|
||||
abort(403, 'Je hebt geen toegang tot deze functie.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,21 @@ class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
return [
|
||||
...parent::share($request),
|
||||
//
|
||||
'auth' => [
|
||||
'user' => $request->user() ? [
|
||||
'id' => $request->user()->id,
|
||||
'name' => $request->user()->name,
|
||||
'email' => $request->user()->email,
|
||||
'functie' => $request->user()->functie,
|
||||
'afdeling' => $request->user()->afdeling,
|
||||
'roles' => $request->user()->roles->pluck('naam'),
|
||||
] : null,
|
||||
],
|
||||
'flash' => [
|
||||
'success' => $request->session()->get('success'),
|
||||
'error' => $request->session()->get('error'),
|
||||
],
|
||||
'locale' => app()->getLocale(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
|
||||
58
app/Providers/FortifyServiceProvider.php
Normal file
58
app/Providers/FortifyServiceProvider.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
app()->singleton(\Laravel\Fortify\Contracts\CreatesNewUsers::class, CreateNewUser::class);
|
||||
app()->singleton(\Laravel\Fortify\Contracts\UpdatesUserProfileInformation::class, UpdateUserProfileInformation::class);
|
||||
app()->singleton(\Laravel\Fortify\Contracts\UpdatesUserPasswords::class, UpdateUserPassword::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
|
||||
Fortify::loginView(fn () => Inertia::render('Auth/Login'));
|
||||
Fortify::registerView(fn () => Inertia::render('Auth/Register'));
|
||||
Fortify::requestPasswordResetLinkView(fn () => Inertia::render('Auth/ForgotPassword'));
|
||||
Fortify::resetPasswordView(fn ($request) => Inertia::render('Auth/ResetPassword', [
|
||||
'token' => $request->route('token'),
|
||||
'email' => $request->query('email'),
|
||||
]));
|
||||
Fortify::verifyEmailView(fn () => Inertia::render('Auth/VerifyEmail'));
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
}
|
||||
}
|
||||
165
app/Services/MapDataService.php
Normal file
165
app/Services/MapDataService.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Thema;
|
||||
use App\Models\Project;
|
||||
use App\Models\Afhankelijkheid;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MapDataService
|
||||
{
|
||||
/**
|
||||
* Build the Level 1 (Strategy) metro map data.
|
||||
* Each theme = a metro line, each project = a station.
|
||||
*/
|
||||
public function getStrategyMap(): array
|
||||
{
|
||||
$themas = Thema::with([
|
||||
'speerpunten.projects' => function ($q) {
|
||||
$q->with('eigenaar')
|
||||
->withCount(['documents', 'commitments', 'risicos', 'fases']);
|
||||
}
|
||||
])->get();
|
||||
|
||||
$lines = [];
|
||||
$nodes = [];
|
||||
$connections = [];
|
||||
|
||||
$lineColors = ['#00d2ff', '#e94560', '#00ff88', '#7b68ee', '#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff'];
|
||||
$yOffset = 0;
|
||||
|
||||
foreach ($themas as $index => $thema) {
|
||||
$color = $lineColors[$index % count($lineColors)];
|
||||
$lines[] = [
|
||||
'id' => "thema-{$thema->id}",
|
||||
'name' => $thema->naam,
|
||||
'color' => $color,
|
||||
];
|
||||
|
||||
$projects = $thema->speerpunten->flatMap->projects;
|
||||
$xOffset = -200;
|
||||
|
||||
foreach ($projects as $order => $project) {
|
||||
$nodes[] = [
|
||||
'id' => "project-{$project->id}",
|
||||
'entityId' => $project->id,
|
||||
'entityType' => 'project',
|
||||
'name' => $project->naam,
|
||||
'lineId' => "thema-{$thema->id}",
|
||||
'x' => $xOffset + ($order * 200),
|
||||
'y' => $yOffset,
|
||||
'order' => $order + 1,
|
||||
'status' => $project->status->value,
|
||||
'description' => Str::limit($project->beschrijving, 100),
|
||||
'owner' => $project->eigenaar?->name,
|
||||
'badge' => ucfirst(str_replace('_', ' ', $project->status->value)),
|
||||
'children' => $project->documents_count + $project->commitments_count,
|
||||
];
|
||||
}
|
||||
|
||||
$yOffset += 130;
|
||||
}
|
||||
|
||||
// Get dependencies as connections
|
||||
$dependencies = Afhankelijkheid::all();
|
||||
foreach ($dependencies as $dep) {
|
||||
$connections[] = [
|
||||
'from' => "project-{$dep->project_id}",
|
||||
'to' => "project-{$dep->afhankelijk_van_project_id}",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'lines' => $lines,
|
||||
'nodes' => $nodes,
|
||||
'connections' => $connections,
|
||||
'level' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Level 2 (Project) metro map data.
|
||||
* The project's lifecycle phases = a metro line, milestones = stations.
|
||||
*/
|
||||
public function getProjectMap(int $projectId): array
|
||||
{
|
||||
$project = Project::with([
|
||||
'fases',
|
||||
'commitments' => fn ($q) => $q->with('eigenaar'),
|
||||
'documents',
|
||||
'risicos',
|
||||
'besluiten',
|
||||
])->findOrFail($projectId);
|
||||
|
||||
$lines = [
|
||||
['id' => 'lifecycle', 'name' => $project->naam, 'color' => '#00d2ff'],
|
||||
['id' => 'commitments', 'name' => 'Commitments', 'color' => '#e94560'],
|
||||
['id' => 'documents', 'name' => 'Documenten', 'color' => '#7b68ee'],
|
||||
];
|
||||
|
||||
$nodes = [];
|
||||
$xOffset = -300;
|
||||
|
||||
// Phase nodes on lifecycle line
|
||||
foreach ($project->fases->sortBy('type') as $order => $fase) {
|
||||
$nodes[] = [
|
||||
'id' => "fase-{$fase->id}",
|
||||
'entityId' => $fase->id,
|
||||
'entityType' => 'fase',
|
||||
'name' => ucfirst(str_replace('_', ' ', $fase->type->value)),
|
||||
'lineId' => 'lifecycle',
|
||||
'x' => $xOffset + ($order * 180),
|
||||
'y' => -50,
|
||||
'order' => $order + 1,
|
||||
'status' => $fase->status->value,
|
||||
'badge' => ucfirst($fase->status->value),
|
||||
];
|
||||
}
|
||||
|
||||
// Commitment nodes
|
||||
foreach ($project->commitments as $order => $commitment) {
|
||||
$nodes[] = [
|
||||
'id' => "commitment-{$commitment->id}",
|
||||
'entityId' => $commitment->id,
|
||||
'entityType' => 'commitment',
|
||||
'name' => Str::limit($commitment->beschrijving, 40),
|
||||
'lineId' => 'commitments',
|
||||
'x' => $xOffset + ($order * 180),
|
||||
'y' => 80,
|
||||
'order' => $order + 1,
|
||||
'status' => $commitment->status->value,
|
||||
'owner' => $commitment->eigenaar?->name,
|
||||
'badge' => $commitment->deadline?->format('d M'),
|
||||
];
|
||||
}
|
||||
|
||||
// Document nodes
|
||||
foreach ($project->documents as $order => $doc) {
|
||||
$nodes[] = [
|
||||
'id' => "document-{$doc->id}",
|
||||
'entityId' => $doc->id,
|
||||
'entityType' => 'document',
|
||||
'name' => $doc->titel,
|
||||
'lineId' => 'documents',
|
||||
'x' => $xOffset + ($order * 180),
|
||||
'y' => 210,
|
||||
'order' => $order + 1,
|
||||
'status' => 'active',
|
||||
'badge' => "v{$doc->versie}",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'lines' => $lines,
|
||||
'nodes' => $nodes,
|
||||
'connections' => [],
|
||||
'level' => 2,
|
||||
'project' => [
|
||||
'id' => $project->id,
|
||||
'naam' => $project->naam,
|
||||
'status' => $project->status->value,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
186
app/Services/ProjectService.php
Normal file
186
app/Services/ProjectService.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\AuditLog;
|
||||
use App\Enums\ProjectStatus;
|
||||
use App\Enums\FaseType;
|
||||
use App\Enums\FaseStatus;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProjectService
|
||||
{
|
||||
/**
|
||||
* Get all projects with their relationships for the metro map.
|
||||
*/
|
||||
public function getAllForMap(): Collection
|
||||
{
|
||||
return Project::with(['eigenaar', 'speerpunt.thema', 'fases', 'risicos', 'commitments'])
|
||||
->withCount(['documents', 'commitments', 'risicos'])
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single project with full details.
|
||||
*/
|
||||
public function getWithDetails(int $id): Project
|
||||
{
|
||||
return Project::with([
|
||||
'eigenaar',
|
||||
'speerpunt.thema',
|
||||
'fases',
|
||||
'risicos',
|
||||
'commitments.acties',
|
||||
'commitments.eigenaar',
|
||||
'documents',
|
||||
'besluiten',
|
||||
'teamleden',
|
||||
'afhankelijkheden.afhankelijkVan',
|
||||
'overdrachtsplannen.criteria',
|
||||
])->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project with initial phase.
|
||||
*/
|
||||
public function create(array $data): Project
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$project = Project::create([
|
||||
'naam' => $data['naam'],
|
||||
'beschrijving' => $data['beschrijving'] ?? '',
|
||||
'eigenaar_id' => $data['eigenaar_id'] ?? Auth::id(),
|
||||
'speerpunt_id' => $data['speerpunt_id'] ?? null,
|
||||
'status' => ProjectStatus::Signaal,
|
||||
'prioriteit' => $data['prioriteit'] ?? \App\Enums\Prioriteit::Midden,
|
||||
'startdatum' => $data['startdatum'] ?? now(),
|
||||
'streef_einddatum' => $data['streef_einddatum'] ?? null,
|
||||
]);
|
||||
|
||||
// Create initial "signaal" phase
|
||||
$project->fases()->create([
|
||||
'type' => FaseType::Signaal,
|
||||
'status' => FaseStatus::Actief,
|
||||
'startdatum' => now(),
|
||||
]);
|
||||
|
||||
// Assign creator as project owner
|
||||
$project->teamleden()->attach(Auth::id(), ['rol' => \App\Enums\ProjectRol::Eigenaar]);
|
||||
|
||||
$this->audit('created', $project);
|
||||
|
||||
return $project;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project details.
|
||||
*/
|
||||
public function update(Project $project, array $data): Project
|
||||
{
|
||||
$project->update(array_filter([
|
||||
'naam' => $data['naam'] ?? null,
|
||||
'beschrijving' => $data['beschrijving'] ?? null,
|
||||
'prioriteit' => $data['prioriteit'] ?? null,
|
||||
'speerpunt_id' => $data['speerpunt_id'] ?? null,
|
||||
'streef_einddatum' => $data['streef_einddatum'] ?? null,
|
||||
], fn ($v) => $v !== null));
|
||||
|
||||
$this->audit('updated', $project);
|
||||
|
||||
return $project->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a project to the next phase.
|
||||
*/
|
||||
public function transitionPhase(Project $project, ProjectStatus $newStatus): Project
|
||||
{
|
||||
return DB::transaction(function () use ($project, $newStatus) {
|
||||
$oldStatus = $project->status;
|
||||
|
||||
// Close current active phase
|
||||
$project->fases()
|
||||
->where('status', FaseStatus::Actief)
|
||||
->update([
|
||||
'status' => FaseStatus::Afgerond->value,
|
||||
'einddatum' => now(),
|
||||
]);
|
||||
|
||||
// Create new phase (if it maps to a FaseType)
|
||||
$faseType = FaseType::tryFrom($newStatus->value);
|
||||
if ($faseType) {
|
||||
$project->fases()->create([
|
||||
'type' => $faseType,
|
||||
'status' => FaseStatus::Actief,
|
||||
'startdatum' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Update project status
|
||||
$project->update(['status' => $newStatus]);
|
||||
|
||||
$this->audit('phase_transition', $project, [
|
||||
'from' => $oldStatus->value,
|
||||
'to' => $newStatus->value,
|
||||
]);
|
||||
|
||||
return $project->fresh(['fases']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Park a project (temporarily halt).
|
||||
*/
|
||||
public function park(Project $project, string $reason = ''): Project
|
||||
{
|
||||
return $this->transitionToSpecialStatus($project, ProjectStatus::Geparkeerd, $reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a project permanently.
|
||||
*/
|
||||
public function stop(Project $project, string $reason = ''): Project
|
||||
{
|
||||
return $this->transitionToSpecialStatus($project, ProjectStatus::Gestopt, $reason);
|
||||
}
|
||||
|
||||
private function transitionToSpecialStatus(Project $project, ProjectStatus $status, string $reason): Project
|
||||
{
|
||||
return DB::transaction(function () use ($project, $status, $reason) {
|
||||
$oldStatus = $project->status;
|
||||
|
||||
$project->fases()
|
||||
->where('status', FaseStatus::Actief)
|
||||
->update([
|
||||
'status' => FaseStatus::Afgerond->value,
|
||||
'einddatum' => now(),
|
||||
'opmerkingen' => $reason,
|
||||
]);
|
||||
|
||||
$project->update(['status' => $status]);
|
||||
|
||||
$this->audit('status_change', $project, [
|
||||
'from' => $oldStatus->value,
|
||||
'to' => $status->value,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
return $project->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
private function audit(string $action, Project $project, ?array $extra = null): void
|
||||
{
|
||||
AuditLog::create([
|
||||
'user_id' => Auth::id(),
|
||||
'action' => "project.{$action}",
|
||||
'entity_type' => 'project',
|
||||
'entity_id' => $project->id,
|
||||
'payload' => $extra,
|
||||
]);
|
||||
}
|
||||
}
|
||||
60
app/Services/ThemaService.php
Normal file
60
app/Services/ThemaService.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Thema;
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ThemaService
|
||||
{
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Thema::with(['speerpunten.projects'])->get();
|
||||
}
|
||||
|
||||
public function getForMap(): Collection
|
||||
{
|
||||
return Thema::with([
|
||||
'speerpunten.projects' => function ($q) {
|
||||
$q->with('eigenaar')
|
||||
->withCount(['documents', 'commitments', 'risicos']);
|
||||
}
|
||||
])->get();
|
||||
}
|
||||
|
||||
public function create(array $data): Thema
|
||||
{
|
||||
$thema = Thema::create([
|
||||
'naam' => $data['naam'],
|
||||
'beschrijving' => $data['beschrijving'] ?? '',
|
||||
'prioriteit' => $data['prioriteit'] ?? \App\Enums\Prioriteit::Midden,
|
||||
'periode_start' => $data['periode_start'] ?? null,
|
||||
'periode_eind' => $data['periode_eind'] ?? null,
|
||||
]);
|
||||
|
||||
AuditLog::create([
|
||||
'user_id' => Auth::id(),
|
||||
'action' => 'thema.created',
|
||||
'entity_type' => 'thema',
|
||||
'entity_id' => $thema->id,
|
||||
]);
|
||||
|
||||
return $thema;
|
||||
}
|
||||
|
||||
public function update(Thema $thema, array $data): Thema
|
||||
{
|
||||
$thema->update(array_filter($data, fn ($v) => $v !== null));
|
||||
|
||||
AuditLog::create([
|
||||
'user_id' => Auth::id(),
|
||||
'action' => 'thema.updated',
|
||||
'entity_type' => 'thema',
|
||||
'entity_id' => $thema->id,
|
||||
]);
|
||||
|
||||
return $thema->fresh();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
@@ -14,6 +15,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||
]);
|
||||
$middleware->alias([
|
||||
'role' => \App\Http\Middleware\CheckRole::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@@ -4,4 +4,5 @@ use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"inertiajs/inertia-laravel": "^3.0",
|
||||
"laravel/fortify": "^1.36",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
354
composer.lock
generated
354
composer.lock
generated
@@ -4,8 +4,63 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "1c6dc1e0948ef3b8fd1905a824a40c58",
|
||||
"content-hash": "49877ead17f176f4f560ededac27ed08",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "v3.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Bacon/BaconQrCode.git",
|
||||
"reference": "3feed0e212b8412cc5d2612706744789b0615824"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824",
|
||||
"reference": "3feed0e212b8412cc5d2612706744789b0615824",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dasprid/enum": "^1.0.3",
|
||||
"ext-iconv": "*",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phly/keep-a-changelog": "^2.12",
|
||||
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
|
||||
"spatie/phpunit-snapshot-assertions": "^5.1.5",
|
||||
"spatie/pixelmatch-php": "^1.2.0",
|
||||
"squizlabs/php_codesniffer": "^3.9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "to generate QR code images"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BaconQrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||
"support": {
|
||||
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4"
|
||||
},
|
||||
"time": "2026-03-16T01:01:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.8",
|
||||
@@ -135,6 +190,56 @@
|
||||
],
|
||||
"time": "2024-02-09T16:56:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dasprid/enum",
|
||||
"version": "1.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DASPRiD/Enum.git",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1 <9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"DASPRiD\\Enum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP 7.1 enum implementation",
|
||||
"keywords": [
|
||||
"enum",
|
||||
"map"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||
},
|
||||
"time": "2025-09-16T12:23:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dflydev/dot-access-data",
|
||||
"version": "v3.0.3",
|
||||
@@ -1126,6 +1231,69 @@
|
||||
},
|
||||
"time": "2026-03-25T21:07:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/fortify",
|
||||
"version": "v1.36.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/fortify.git",
|
||||
"reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/fortify/zipball/b36e0782e6f5f6cfbab34327895a63b7c4c031f9",
|
||||
"reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"ext-json": "*",
|
||||
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
|
||||
"php": "^8.1",
|
||||
"pragmarx/google2fa": "^9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
|
||||
"phpstan/phpstan": "^1.10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Fortify\\FortifyServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Fortify\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Backend controllers and scaffolding for Laravel authentication.",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/fortify/issues",
|
||||
"source": "https://github.com/laravel/fortify"
|
||||
},
|
||||
"time": "2026-03-20T20:13:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v13.2.0",
|
||||
@@ -1406,6 +1574,69 @@
|
||||
},
|
||||
"time": "2026-03-23T14:35:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sanctum",
|
||||
"version": "v4.3.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/sanctum.git",
|
||||
"reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
|
||||
"reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
||||
"illuminate/database": "^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||
"php": "^8.2",
|
||||
"symfony/console": "^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
||||
"phpstan/phpstan": "^1.10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Sanctum\\SanctumServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Sanctum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"laravel",
|
||||
"sanctum"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/sanctum/issues",
|
||||
"source": "https://github.com/laravel/sanctum"
|
||||
},
|
||||
"time": "2026-02-07T17:19:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v2.0.10",
|
||||
@@ -2606,6 +2837,75 @@
|
||||
],
|
||||
"time": "2026-02-16T23:10:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.1.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/constant_time_encoding.git",
|
||||
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
|
||||
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8"
|
||||
},
|
||||
"require-dev": {
|
||||
"infection/infection": "^0",
|
||||
"nikic/php-fuzzer": "^0",
|
||||
"phpunit/phpunit": "^9|^10|^11",
|
||||
"vimeo/psalm": "^4|^5|^6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ParagonIE\\ConstantTime\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paragon Initiative Enterprises",
|
||||
"email": "security@paragonie.com",
|
||||
"homepage": "https://paragonie.com",
|
||||
"role": "Maintainer"
|
||||
},
|
||||
{
|
||||
"name": "Steve 'Sc00bz' Thomas",
|
||||
"email": "steve@tobtu.com",
|
||||
"homepage": "https://www.tobtu.com",
|
||||
"role": "Original Developer"
|
||||
}
|
||||
],
|
||||
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
|
||||
"keywords": [
|
||||
"base16",
|
||||
"base32",
|
||||
"base32_decode",
|
||||
"base32_encode",
|
||||
"base64",
|
||||
"base64_decode",
|
||||
"base64_encode",
|
||||
"bin2hex",
|
||||
"encoding",
|
||||
"hex",
|
||||
"hex2bin",
|
||||
"rfc4648"
|
||||
],
|
||||
"support": {
|
||||
"email": "info@paragonie.com",
|
||||
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
|
||||
"source": "https://github.com/paragonie/constant_time_encoding"
|
||||
},
|
||||
"time": "2025-09-24T15:06:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.5",
|
||||
@@ -2681,6 +2981,58 @@
|
||||
],
|
||||
"time": "2025-12-27T19:41:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pragmarx/google2fa",
|
||||
"version": "v9.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/antonioribeiro/google2fa.git",
|
||||
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf",
|
||||
"reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
|
||||
"php": "^7.1|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.9",
|
||||
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PragmaRX\\Google2FA\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Antonio Carlos Ribeiro",
|
||||
"email": "acr@antoniocarlosribeiro.com",
|
||||
"role": "Creator & Designer"
|
||||
}
|
||||
],
|
||||
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
|
||||
"keywords": [
|
||||
"2fa",
|
||||
"Authentication",
|
||||
"Two Factor Authentication",
|
||||
"google2fa"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/antonioribeiro/google2fa/issues",
|
||||
"source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0"
|
||||
},
|
||||
"time": "2025-09-19T22:51:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
|
||||
154
config/fortify.php
Normal file
154
config/fortify.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Fortify will use while
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Password Broker
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which password broker Fortify can use when a user
|
||||
| is resetting their password. This configured value should match one
|
||||
| of your password brokers setup in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => 'users',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Username / Email
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines which model attribute should be considered as your
|
||||
| application's "username" field. Typically, this might be the email
|
||||
| address of the users but you are free to change this value here.
|
||||
|
|
||||
| Out of the box, Fortify expects forgot password and reset password
|
||||
| requests to have a field named 'email'. If the application uses
|
||||
| another name for the field you may define it below as needed.
|
||||
|
|
||||
*/
|
||||
|
||||
'username' => 'email',
|
||||
|
||||
'email' => 'email',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Lowercase Usernames
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines whether usernames should be lowercased before saving
|
||||
| them in the database, as some database system string fields are case
|
||||
| sensitive. You may disable this for your application if necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'lowercase_usernames' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Home Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the path where users will get redirected during
|
||||
| authentication or password reset when the operations are successful
|
||||
| and the user is authenticated. You are free to change this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'home' => '/dashboard',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Prefix / Subdomain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which prefix Fortify will assign to all the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| subdomain under which all of the Fortify routes will be available.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => '',
|
||||
|
||||
'domain' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which middleware Fortify will assign to the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| these middleware but typically this provided default is preferred.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Fortify will throttle logins to five requests per minute for
|
||||
| every email and IP address combination. However, if you would like to
|
||||
| specify a custom rate limiter to call then you may specify it here.
|
||||
|
|
||||
*/
|
||||
|
||||
'limiters' => [
|
||||
'login' => 'login',
|
||||
'two-factor' => 'two-factor',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register View Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify if the routes returning views should be disabled as
|
||||
| you may not need them when building your own application. This may be
|
||||
| especially true if you're writing a custom single-page application.
|
||||
|
|
||||
*/
|
||||
|
||||
'views' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Features
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some of the Fortify features are optional. You may disable the features
|
||||
| by removing them from this array. You're free to only remove some of
|
||||
| these features or you can even remove all of these if you need to.
|
||||
|
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
Features::registration(),
|
||||
Features::resetPasswords(),
|
||||
Features::emailVerification(),
|
||||
Features::updateProfileInformation(),
|
||||
Features::updatePasswords(),
|
||||
],
|
||||
|
||||
];
|
||||
84
config/sanctum.php
Normal file
84
config/sanctum.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort(),
|
||||
// Sanctum::currentRequestHost(),
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
||||
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
|
||||
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,42 @@
|
||||
<?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::table('users', function (Blueprint $table) {
|
||||
$table->text('two_factor_secret')
|
||||
->after('password')
|
||||
->nullable();
|
||||
|
||||
$table->text('two_factor_recovery_codes')
|
||||
->after('two_factor_secret')
|
||||
->nullable();
|
||||
|
||||
$table->timestamp('two_factor_confirmed_at')
|
||||
->after('two_factor_recovery_codes')
|
||||
->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'two_factor_secret',
|
||||
'two_factor_recovery_codes',
|
||||
'two_factor_confirmed_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('tokenable');
|
||||
$table->text('name');
|
||||
$table->string('token', 64)->unique();
|
||||
$table->text('abilities')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('personal_access_tokens');
|
||||
}
|
||||
};
|
||||
@@ -2,24 +2,551 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\CommitmentStatus;
|
||||
use App\Enums\FaseStatus;
|
||||
use App\Enums\FaseType;
|
||||
use App\Enums\Prioriteit;
|
||||
use App\Enums\ProjectRol;
|
||||
use App\Enums\ProjectStatus;
|
||||
use App\Enums\SpeerpuntStatus;
|
||||
use App\Models\Afhankelijkheid;
|
||||
use App\Models\Commitment;
|
||||
use App\Models\Document;
|
||||
use App\Models\Fase;
|
||||
use App\Models\Project;
|
||||
use App\Models\Role;
|
||||
use App\Models\Speerpunt;
|
||||
use App\Models\Thema;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 1. System roles
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
$roleAdmin = Role::create([
|
||||
'naam' => 'admin',
|
||||
'beschrijving' => 'Volledige toegang tot het platform',
|
||||
'permissies' => ['*'],
|
||||
]);
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
$roleProjectOwner = Role::create([
|
||||
'naam' => 'project_owner',
|
||||
'beschrijving' => 'Kan projecten beheren en bewerken',
|
||||
'permissies' => ['projects.manage', 'commitments.manage', 'documents.manage'],
|
||||
]);
|
||||
|
||||
$roleTeamMember = Role::create([
|
||||
'naam' => 'team_member',
|
||||
'beschrijving' => 'Kan bijdragen aan toegewezen projecten',
|
||||
'permissies' => ['projects.view', 'commitments.edit', 'documents.upload'],
|
||||
]);
|
||||
|
||||
Role::create([
|
||||
'naam' => 'viewer',
|
||||
'beschrijving' => 'Alleen-lezen toegang',
|
||||
'permissies' => ['projects.view', 'documents.view'],
|
||||
]);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 2. Users
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
$adminUser = User::create([
|
||||
'name' => 'Admin Gebruiker',
|
||||
'email' => 'admin@innovatieplatform.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'functie' => 'Platform Beheerder',
|
||||
'afdeling' => 'R&D Lab',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
$adminUser->roles()->attach($roleAdmin);
|
||||
|
||||
$testUser = User::create([
|
||||
'name' => 'Rene de Ren',
|
||||
'email' => 'rene@wbd-rd.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'functie' => 'R&D Engineer',
|
||||
'afdeling' => 'R&D Lab',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
$testUser->roles()->attach($roleProjectOwner);
|
||||
|
||||
$analyst = User::create([
|
||||
'name' => 'Lisanne Bakker',
|
||||
'email' => 'l.bakker@wbd-rd.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'functie' => 'Data Analist',
|
||||
'afdeling' => 'Watermanagement',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
$analyst->roles()->attach($roleTeamMember);
|
||||
|
||||
$engineer = User::create([
|
||||
'name' => 'Joris van Dam',
|
||||
'email' => 'j.vandam@wbd-rd.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'functie' => 'Senior Technisch Adviseur',
|
||||
'afdeling' => 'Infrastructuur',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
$engineer->roles()->attach($roleTeamMember);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 3. Themas (4 strategic themes)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
$themaWater = Thema::create([
|
||||
'naam' => 'Waterkwaliteit',
|
||||
'beschrijving' => 'Verbetering van de kwaliteit van oppervlaktewater en grondwater door innovatieve monitoring- en zuiveringstechnieken.',
|
||||
'prioriteit' => Prioriteit::Hoog,
|
||||
'periode_start' => '2025-01-01',
|
||||
'periode_eind' => '2028-12-31',
|
||||
]);
|
||||
|
||||
$themaInfra = Thema::create([
|
||||
'naam' => 'Slimme Infrastructuur',
|
||||
'beschrijving' => 'Digitalisering en automatisering van waterkeringen, gemalen en sluizen voor efficiënter beheer en snellere responstijden.',
|
||||
'prioriteit' => Prioriteit::Hoog,
|
||||
'periode_start' => '2025-01-01',
|
||||
'periode_eind' => '2028-12-31',
|
||||
]);
|
||||
|
||||
$themaData = Thema::create([
|
||||
'naam' => 'Data-gedreven Beheer',
|
||||
'beschrijving' => 'Inzet van data-analyse, AI en digitale tweelingen voor betere besluitvorming in waterbeheer.',
|
||||
'prioriteit' => Prioriteit::Midden,
|
||||
'periode_start' => '2025-06-01',
|
||||
'periode_eind' => '2029-06-30',
|
||||
]);
|
||||
|
||||
$themaDuurzaam = Thema::create([
|
||||
'naam' => 'Duurzaamheid & Klimaatadaptatie',
|
||||
'beschrijving' => 'Innovaties gericht op energieneutraliteit, circulaire waterketens en klimaatrobuuste inrichting van het beheergebied.',
|
||||
'prioriteit' => Prioriteit::Midden,
|
||||
'periode_start' => '2025-01-01',
|
||||
'periode_eind' => '2030-12-31',
|
||||
]);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 4. Speerpunten (2 per thema)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Waterkwaliteit
|
||||
$spWaterMonitoring = Speerpunt::create([
|
||||
'thema_id' => $themaWater->id,
|
||||
'naam' => 'Real-time Watermonitoring',
|
||||
'beschrijving' => 'Continue meting van waterkwaliteitsparameters via IoT-sensoren in het beheergebied.',
|
||||
'eigenaar_id' => $testUser->id,
|
||||
'status' => SpeerpuntStatus::Actief,
|
||||
]);
|
||||
|
||||
$spMicropollution = Speerpunt::create([
|
||||
'thema_id' => $themaWater->id,
|
||||
'naam' => 'Microverontreinigingen',
|
||||
'beschrijving' => 'Detectie en verwijdering van opkomende stoffen zoals medicijnresten en PFAS.',
|
||||
'eigenaar_id' => $engineer->id,
|
||||
'status' => SpeerpuntStatus::Concept,
|
||||
]);
|
||||
|
||||
// Slimme Infrastructuur
|
||||
$spDigitaalBeheer = Speerpunt::create([
|
||||
'thema_id' => $themaInfra->id,
|
||||
'naam' => 'Digitaal Kunstwerkenregister',
|
||||
'beschrijving' => 'Volledig digitaal beheer van kunstwerken met BIM-koppeling en conditiebewaking.',
|
||||
'eigenaar_id' => $engineer->id,
|
||||
'status' => SpeerpuntStatus::Actief,
|
||||
]);
|
||||
|
||||
$spSmartGemaal = Speerpunt::create([
|
||||
'thema_id' => $themaInfra->id,
|
||||
'naam' => 'Slimme Gemaalbesturing',
|
||||
'beschrijving' => 'Predictieve sturing van gemalen op basis van weersvoorspelling en waterstanden.',
|
||||
'eigenaar_id' => $testUser->id,
|
||||
'status' => SpeerpuntStatus::Actief,
|
||||
]);
|
||||
|
||||
// Data-gedreven Beheer
|
||||
$spDigitaalTwin = Speerpunt::create([
|
||||
'thema_id' => $themaData->id,
|
||||
'naam' => 'Digitale Tweeling Watersysteem',
|
||||
'beschrijving' => 'Virtueel model van het watersysteem voor scenario-analyse en operationele ondersteuning.',
|
||||
'eigenaar_id' => $analyst->id,
|
||||
'status' => SpeerpuntStatus::Concept,
|
||||
]);
|
||||
|
||||
$spAIVoorspelling = Speerpunt::create([
|
||||
'thema_id' => $themaData->id,
|
||||
'naam' => 'AI-gestuurde Waterstandsvoorspelling',
|
||||
'beschrijving' => 'Machine learning modellen voor nauwkeurige korte- en middellangetermijnwaterstanden.',
|
||||
'eigenaar_id' => $analyst->id,
|
||||
'status' => SpeerpuntStatus::Actief,
|
||||
]);
|
||||
|
||||
// Duurzaamheid
|
||||
$spEnergieneutraal = Speerpunt::create([
|
||||
'thema_id' => $themaDuurzaam->id,
|
||||
'naam' => 'Energieneutrale Zuivering',
|
||||
'beschrijving' => 'Zelfvoorzienende rwzi\'s door terugwinning van energie uit afvalwater.',
|
||||
'eigenaar_id' => $engineer->id,
|
||||
'status' => SpeerpuntStatus::Actief,
|
||||
]);
|
||||
|
||||
$spCirculaireWater = Speerpunt::create([
|
||||
'thema_id' => $themaDuurzaam->id,
|
||||
'naam' => 'Circulaire Waterketen',
|
||||
'beschrijving' => 'Terugwinning van grondstoffen (fosfaat, cellulose, warmte) uit afvalwater.',
|
||||
'eigenaar_id' => $testUser->id,
|
||||
'status' => SpeerpuntStatus::Concept,
|
||||
]);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 5. Projects (10–12 spread across themes and lifecycle phases)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
$projects = [];
|
||||
|
||||
// --- Waterkwaliteit projects ---
|
||||
$p1 = $this->createProject([
|
||||
'speerpunt_id' => $spWaterMonitoring->id,
|
||||
'naam' => 'LoRaWAN Sensornetwerk Biesbosch',
|
||||
'beschrijving' => 'Uitrol van een draadloos sensornetwerk in het Biesbosch-gebied voor real-time meting van waterkwaliteitsparameters (pH, DO, troebelheid, geleidbaarheid).',
|
||||
'eigenaar_id' => $testUser->id,
|
||||
'status' => ProjectStatus::Pilot,
|
||||
'prioriteit' => Prioriteit::Hoog,
|
||||
'startdatum' => '2025-03-01',
|
||||
'streef_einddatum' => '2025-12-31',
|
||||
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot]);
|
||||
|
||||
$p2 = $this->createProject([
|
||||
'speerpunt_id' => $spWaterMonitoring->id,
|
||||
'naam' => 'Drone-inspectie Waterbodem',
|
||||
'beschrijving' => 'Onderzoek naar de inzet van autonome onderwaterdrones voor sedimentkartering en vervuilingdetectie in kanalen en sloten.',
|
||||
'eigenaar_id' => $engineer->id,
|
||||
'status' => ProjectStatus::Experiment,
|
||||
'prioriteit' => Prioriteit::Midden,
|
||||
'startdatum' => '2025-06-01',
|
||||
'streef_einddatum' => '2026-06-30',
|
||||
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment]);
|
||||
|
||||
$p3 = $this->createProject([
|
||||
'speerpunt_id' => $spMicropollution->id,
|
||||
'naam' => 'PFAS Detectiemethode Ontwikkeling',
|
||||
'beschrijving' => 'Ontwikkeling van een snelle en goedkope veldmethode voor PFAS-detectie in oppervlaktewater, als alternatief voor kostbare laboratoriumanalyse.',
|
||||
'eigenaar_id' => $analyst->id,
|
||||
'status' => ProjectStatus::Verkenning,
|
||||
'prioriteit' => Prioriteit::Hoog,
|
||||
'startdatum' => '2025-09-01',
|
||||
'streef_einddatum' => '2026-09-30',
|
||||
], [FaseType::Signaal, FaseType::Verkenning]);
|
||||
|
||||
// --- Slimme Infrastructuur projects ---
|
||||
$p4 = $this->createProject([
|
||||
'speerpunt_id' => $spDigitaalBeheer->id,
|
||||
'naam' => 'BIM-model Gemaal De Donge',
|
||||
'beschrijving' => 'Digitale driedimensionale representatie van gemaal De Donge inclusief alle technische installaties, leidingen en elektrotechnische componenten.',
|
||||
'eigenaar_id' => $engineer->id,
|
||||
'status' => ProjectStatus::OverdrachtBouwen,
|
||||
'prioriteit' => Prioriteit::Midden,
|
||||
'startdatum' => '2024-09-01',
|
||||
'streef_einddatum' => '2025-06-30',
|
||||
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot, FaseType::Besluitvorming, FaseType::OverdrachtBouwen]);
|
||||
|
||||
$p5 = $this->createProject([
|
||||
'speerpunt_id' => $spSmartGemaal->id,
|
||||
'naam' => 'Predictieve Gemaalbesturing Mark-Vliet',
|
||||
'beschrijving' => 'Implementatie van een ML-algoritme dat op basis van KNMI-weerdata en historische afvoerpatronen de optimale pompsturing berekent voor het Mark-Vliet systeem.',
|
||||
'eigenaar_id' => $testUser->id,
|
||||
'status' => ProjectStatus::Besluitvorming,
|
||||
'prioriteit' => Prioriteit::Hoog,
|
||||
'startdatum' => '2025-01-01',
|
||||
'streef_einddatum' => '2025-12-31',
|
||||
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot, FaseType::Besluitvorming]);
|
||||
|
||||
$p6 = $this->createProject([
|
||||
'speerpunt_id' => $spSmartGemaal->id,
|
||||
'naam' => 'Remote Monitoring Waterkeringen',
|
||||
'beschrijving' => 'Continuemonitoring van primaire waterkeringen met IoT-sensoren voor zakking, piping-detectie en grondwaterstand.',
|
||||
'eigenaar_id' => $engineer->id,
|
||||
'status' => ProjectStatus::Signaal,
|
||||
'prioriteit' => Prioriteit::Laag,
|
||||
'startdatum' => '2026-01-01',
|
||||
'streef_einddatum' => null,
|
||||
], [FaseType::Signaal]);
|
||||
|
||||
// --- Data-gedreven Beheer projects ---
|
||||
$p7 = $this->createProject([
|
||||
'speerpunt_id' => $spDigitaalTwin->id,
|
||||
'naam' => 'Digitale Tweeling Pilot Roosendaalse Vliet',
|
||||
'beschrijving' => 'Eerste proof-of-concept van een digitale tweeling voor het deelgebied Roosendaalse Vliet, gekoppeld aan het SOBEK-hydraulisch model.',
|
||||
'eigenaar_id' => $analyst->id,
|
||||
'status' => ProjectStatus::Concept,
|
||||
'prioriteit' => Prioriteit::Hoog,
|
||||
'startdatum' => '2025-07-01',
|
||||
'streef_einddatum' => '2026-12-31',
|
||||
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept]);
|
||||
|
||||
$p8 = $this->createProject([
|
||||
'speerpunt_id' => $spAIVoorspelling->id,
|
||||
'naam' => 'AI Waterstandsmodel Hollandsch Diep',
|
||||
'beschrijving' => 'Training en validatie van een LSTM-neuraal netwerk voor 48-uurs waterstandsvoorspellingen op het Hollandsch Diep, ter vervanging van het huidige regressiemodel.',
|
||||
'eigenaar_id' => $analyst->id,
|
||||
'status' => ProjectStatus::Experiment,
|
||||
'prioriteit' => Prioriteit::Hoog,
|
||||
'startdatum' => '2025-04-01',
|
||||
'streef_einddatum' => '2026-03-31',
|
||||
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment]);
|
||||
|
||||
$p9 = $this->createProject([
|
||||
'speerpunt_id' => $spAIVoorspelling->id,
|
||||
'naam' => 'Open Data Platform Waterschap',
|
||||
'beschrijving' => 'Ontwikkeling van een publiekstoegankelijk data-portaal voor het ontsluiten van historische en actuele meetdata van het waterschap.',
|
||||
'eigenaar_id' => $testUser->id,
|
||||
'status' => ProjectStatus::Geparkeerd,
|
||||
'prioriteit' => Prioriteit::Laag,
|
||||
'startdatum' => '2024-06-01',
|
||||
'streef_einddatum' => '2025-12-31',
|
||||
], [FaseType::Signaal, FaseType::Verkenning]);
|
||||
|
||||
// --- Duurzaamheid projects ---
|
||||
$p10 = $this->createProject([
|
||||
'speerpunt_id' => $spEnergieneutraal->id,
|
||||
'naam' => 'Biogasopwaardering RWZI Bath',
|
||||
'beschrijving' => 'Opwaardering van slibvergistingsgas naar groengas-kwaliteit voor invoeding op het gasnet en verkoop aan een energiemaatschappij.',
|
||||
'eigenaar_id' => $engineer->id,
|
||||
'status' => ProjectStatus::Evaluatie,
|
||||
'prioriteit' => Prioriteit::Midden,
|
||||
'startdatum' => '2024-01-01',
|
||||
'streef_einddatum' => '2025-06-30',
|
||||
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot, FaseType::Besluitvorming, FaseType::OverdrachtBouwen, FaseType::OverdrachtBeheer, FaseType::Evaluatie]);
|
||||
|
||||
$p11 = $this->createProject([
|
||||
'speerpunt_id' => $spCirculaireWater->id,
|
||||
'naam' => 'Fosfaatterugwinning Struviet',
|
||||
'beschrijving' => 'Implementatie van struvietkristallisatie-technologie op RWZI Nieuw-Vossemeer voor de terugwinning van fosfaat als meststof.',
|
||||
'eigenaar_id' => $testUser->id,
|
||||
'status' => ProjectStatus::Pilot,
|
||||
'prioriteit' => Prioriteit::Midden,
|
||||
'startdatum' => '2025-02-01',
|
||||
'streef_einddatum' => '2026-02-28',
|
||||
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot]);
|
||||
|
||||
$p12 = $this->createProject([
|
||||
'speerpunt_id' => $spEnergieneutraal->id,
|
||||
'naam' => 'Warmteterugwinning Afvalwater Centrum',
|
||||
'beschrijving' => 'Pilotinstallatie voor warmtewisselaars op het rioolstelsel in Breda-centrum om warmte terug te winnen voor stadsverwarming.',
|
||||
'eigenaar_id' => $engineer->id,
|
||||
'status' => ProjectStatus::Verkenning,
|
||||
'prioriteit' => Prioriteit::Midden,
|
||||
'startdatum' => '2025-10-01',
|
||||
'streef_einddatum' => '2027-03-31',
|
||||
], [FaseType::Signaal, FaseType::Verkenning]);
|
||||
|
||||
$projects = [$p1, $p2, $p3, $p4, $p5, $p6, $p7, $p8, $p9, $p10, $p11, $p12];
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 6. Assign team members
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
$p1->teamleden()->attach($analyst->id, ['rol' => ProjectRol::Lid->value]);
|
||||
$p2->teamleden()->attach($testUser->id, ['rol' => ProjectRol::Reviewer->value]);
|
||||
$p5->teamleden()->attach($analyst->id, ['rol' => ProjectRol::Lid->value]);
|
||||
$p5->teamleden()->attach($engineer->id, ['rol' => ProjectRol::Reviewer->value]);
|
||||
$p8->teamleden()->attach($engineer->id, ['rol' => ProjectRol::Lid->value]);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 7. Commitments
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
Commitment::create([
|
||||
'project_id' => $p1->id,
|
||||
'beschrijving' => 'Installatie van 20 sensorknooppunten in het veld vóór einde Q3 2025',
|
||||
'eigenaar_id' => $testUser->id,
|
||||
'deadline' => '2025-09-30',
|
||||
'status' => CommitmentStatus::InUitvoering,
|
||||
'bron' => 'Stuurgroep Waterkwaliteit — 12 maart 2025',
|
||||
]);
|
||||
|
||||
Commitment::create([
|
||||
'project_id' => $p1->id,
|
||||
'beschrijving' => 'Validatierapport sensornauwkeurigheid opleveren aan dataplatformteam',
|
||||
'eigenaar_id' => $analyst->id,
|
||||
'deadline' => '2025-11-30',
|
||||
'status' => CommitmentStatus::Open,
|
||||
'bron' => 'Stuurgroep Waterkwaliteit — 12 maart 2025',
|
||||
]);
|
||||
|
||||
Commitment::create([
|
||||
'project_id' => $p5->id,
|
||||
'beschrijving' => 'Afstemming met Rijkswaterstaat over databeschikbaarheid afvoermetingen',
|
||||
'eigenaar_id' => $engineer->id,
|
||||
'deadline' => '2025-07-31',
|
||||
'status' => CommitmentStatus::Afgerond,
|
||||
'bron' => 'Projectstartup 15 januari 2025',
|
||||
]);
|
||||
|
||||
Commitment::create([
|
||||
'project_id' => $p5->id,
|
||||
'beschrijving' => 'Businesscase energiebesparing opstellen voor directie',
|
||||
'eigenaar_id' => $testUser->id,
|
||||
'deadline' => '2025-10-15',
|
||||
'status' => CommitmentStatus::Open,
|
||||
'bron' => 'Besluitvormingsrapport fase 5',
|
||||
]);
|
||||
|
||||
Commitment::create([
|
||||
'project_id' => $p8->id,
|
||||
'beschrijving' => 'Trainingsdata leveren: minimaal 5 jaar uurlijkse waterstandsmetingen',
|
||||
'eigenaar_id' => $analyst->id,
|
||||
'deadline' => '2025-08-01',
|
||||
'status' => CommitmentStatus::Afgerond,
|
||||
'bron' => 'Projectplan AI Waterstandsmodel v1.0',
|
||||
]);
|
||||
|
||||
Commitment::create([
|
||||
'project_id' => $p10->id,
|
||||
'beschrijving' => 'Eindrapportage energieopbrengst en milieuprestatie biogasinstallatie',
|
||||
'eigenaar_id' => $engineer->id,
|
||||
'deadline' => '2025-05-31',
|
||||
'status' => CommitmentStatus::Afgerond,
|
||||
'bron' => 'Evaluatieprogramma RWZI Bath',
|
||||
]);
|
||||
|
||||
Commitment::create([
|
||||
'project_id' => $p11->id,
|
||||
'beschrijving' => 'Technische specificaties struvietreactor aanleveren aan leverancier',
|
||||
'eigenaar_id' => $testUser->id,
|
||||
'deadline' => '2025-05-01',
|
||||
'status' => CommitmentStatus::Afgerond,
|
||||
'bron' => 'Pilotopzet Fosfaatterugwinning',
|
||||
]);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 8. Documents
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
Document::create([
|
||||
'project_id' => $p1->id,
|
||||
'titel' => 'Technisch Ontwerp LoRaWAN Netwerk',
|
||||
'type' => 'technisch_ontwerp',
|
||||
'inhoud' => 'Systeemarchitectuur, gatewaylocaties, frequentieplan en databeheerprotocol voor het LoRaWAN sensornetwerk in het Biesboschgebied.',
|
||||
'versie' => 2,
|
||||
'auteur_id' => $engineer->id,
|
||||
]);
|
||||
|
||||
Document::create([
|
||||
'project_id' => $p1->id,
|
||||
'titel' => 'Projectplan Fase Pilot',
|
||||
'type' => 'projectplan',
|
||||
'inhoud' => 'Doelstellingen, activiteiten, planning en risico\'s voor de pilotfase van het LoRaWAN sensornetwerk.',
|
||||
'versie' => 1,
|
||||
'auteur_id' => $testUser->id,
|
||||
]);
|
||||
|
||||
Document::create([
|
||||
'project_id' => $p5->id,
|
||||
'titel' => 'Businesscase Predictieve Gemaalbesturing',
|
||||
'type' => 'businesscase',
|
||||
'inhoud' => 'Kosten-batenanalyse voor de implementatie van ML-gestuurde pompsturing, inclusief energiebesparingspotentieel en investeringskosten.',
|
||||
'versie' => 3,
|
||||
'auteur_id' => $testUser->id,
|
||||
]);
|
||||
|
||||
Document::create([
|
||||
'project_id' => $p8->id,
|
||||
'titel' => 'Modelarchitectuur LSTM Waterstandsvoorspelling',
|
||||
'type' => 'technisch_rapport',
|
||||
'inhoud' => 'Gedetailleerde beschrijving van de LSTM-netwerkarchitectuur, feature engineering, trainingsopzet en validatiemethodiek.',
|
||||
'versie' => 1,
|
||||
'auteur_id' => $analyst->id,
|
||||
]);
|
||||
|
||||
Document::create([
|
||||
'project_id' => $p10->id,
|
||||
'titel' => 'Evaluatierapport Biogasopwaardering RWZI Bath',
|
||||
'type' => 'evaluatierapport',
|
||||
'inhoud' => 'Eindresultaten van de biogasopwaarderingsinstallatie: energieproductie, CH4-gehalte, opbrengst en geleerde lessen.',
|
||||
'versie' => 1,
|
||||
'auteur_id' => $engineer->id,
|
||||
]);
|
||||
|
||||
Document::create([
|
||||
'project_id' => $p4->id,
|
||||
'titel' => 'BIM-protocol Kunstwerken v2.0',
|
||||
'type' => 'protocol',
|
||||
'inhoud' => 'Afspraken voor objectcodering, LOD-niveaus, attribuutvelden en uitwisselformaten (IFC) voor het BIM-model van kunstwerken.',
|
||||
'versie' => 2,
|
||||
'auteur_id' => $engineer->id,
|
||||
]);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 9. Dependencies between projects
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
// AI Waterstandsmodel is afhankelijk van LoRaWAN Sensornetwerk (databron)
|
||||
Afhankelijkheid::create([
|
||||
'project_id' => $p8->id,
|
||||
'afhankelijk_van_project_id' => $p1->id,
|
||||
'type' => 'data',
|
||||
'beschrijving' => 'Het AI-model heeft real-time sensordata nodig uit het LoRaWAN netwerk als input feature.',
|
||||
'status' => 'actief',
|
||||
]);
|
||||
|
||||
// Digitale Tweeling is afhankelijk van AI Waterstandsmodel
|
||||
Afhankelijkheid::create([
|
||||
'project_id' => $p7->id,
|
||||
'afhankelijk_van_project_id' => $p8->id,
|
||||
'type' => 'technisch',
|
||||
'beschrijving' => 'De digitale tweeling integreert de voorspellingsmodule van het AI waterstandsmodel.',
|
||||
'status' => 'actief',
|
||||
]);
|
||||
|
||||
// Predictieve Gemaalbesturing is afhankelijk van AI Waterstandsmodel
|
||||
Afhankelijkheid::create([
|
||||
'project_id' => $p5->id,
|
||||
'afhankelijk_van_project_id' => $p8->id,
|
||||
'type' => 'technisch',
|
||||
'beschrijving' => 'De predictieve besturing gebruikt de 48-uurs waterstandsvoorspellingen als stuurinput.',
|
||||
'status' => 'actief',
|
||||
]);
|
||||
|
||||
// Remote Monitoring Waterkeringen is afhankelijk van LoRaWAN Sensornetwerk
|
||||
Afhankelijkheid::create([
|
||||
'project_id' => $p6->id,
|
||||
'afhankelijk_van_project_id' => $p1->id,
|
||||
'type' => 'infrastructuur',
|
||||
'beschrijving' => 'Het monitoring-project maakt gebruik van de LoRaWAN-infrastructuur voor datatransport.',
|
||||
'status' => 'gepland',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: create a project with its completed and active phases.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
* @param FaseType[] $faseTypes All phase types in chronological order; the last one is the active phase.
|
||||
*/
|
||||
private function createProject(array $attributes, array $faseTypes): Project
|
||||
{
|
||||
$project = Project::create($attributes);
|
||||
|
||||
// Attach the project owner as eigenaar in the pivot table
|
||||
$project->teamleden()->attach($attributes['eigenaar_id'], ['rol' => ProjectRol::Eigenaar->value]);
|
||||
|
||||
foreach ($faseTypes as $index => $faseType) {
|
||||
$isLast = $index === count($faseTypes) - 1;
|
||||
|
||||
Fase::create([
|
||||
'project_id' => $project->id,
|
||||
'type' => $faseType,
|
||||
'status' => $isLast ? FaseStatus::Actief : FaseStatus::Afgerond,
|
||||
'startdatum' => now()->subMonths(count($faseTypes) - 1 - $index),
|
||||
'einddatum' => $isLast ? null : now()->subMonths(count($faseTypes) - 2 - $index),
|
||||
]);
|
||||
}
|
||||
|
||||
return $project;
|
||||
}
|
||||
}
|
||||
|
||||
489
package-lock.json
generated
489
package-lock.json
generated
@@ -5,9 +5,13 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/press-start-2p": "^5.2.7",
|
||||
"@fontsource/vt323": "^5.2.7",
|
||||
"@inertiajs/vue3": "^3.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"d3": "^7.9.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31"
|
||||
},
|
||||
@@ -97,6 +101,33 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/ibm-plex-mono": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz",
|
||||
"integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/press-start-2p": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/press-start-2p/-/press-start-2p-5.2.7.tgz",
|
||||
"integrity": "sha512-ezSxYRchANoteja170ZHh2Tt4oejmVEvX42VFPOVVvERSZ68pcuZFjbhHfrK/sNjYmaGg656y7B3431ojO9PCQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/vt323": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/vt323/-/vt323-5.2.7.tgz",
|
||||
"integrity": "sha512-8JTMM23vMhQxin9Cn/ijty8cNwXW4INrln0VAJ2227Rz0CVfkzM3qr3l/CqudZJ6BXCnbCGUTdf2ym3cTNex8A==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@inertiajs/core": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.0.1.tgz",
|
||||
@@ -1071,6 +1102,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
@@ -1117,6 +1157,416 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-brush": "3",
|
||||
"d3-chord": "3",
|
||||
"d3-color": "3",
|
||||
"d3-contour": "4",
|
||||
"d3-delaunay": "6",
|
||||
"d3-dispatch": "3",
|
||||
"d3-drag": "3",
|
||||
"d3-dsv": "3",
|
||||
"d3-ease": "3",
|
||||
"d3-fetch": "3",
|
||||
"d3-force": "3",
|
||||
"d3-format": "3",
|
||||
"d3-geo": "3",
|
||||
"d3-hierarchy": "3",
|
||||
"d3-interpolate": "3",
|
||||
"d3-path": "3",
|
||||
"d3-polygon": "3",
|
||||
"d3-quadtree": "3",
|
||||
"d3-random": "3",
|
||||
"d3-scale": "4",
|
||||
"d3-scale-chromatic": "3",
|
||||
"d3-selection": "3",
|
||||
"d3-shape": "3",
|
||||
"d3-time": "3",
|
||||
"d3-time-format": "4",
|
||||
"d3-timer": "3",
|
||||
"d3-transition": "3",
|
||||
"d3-zoom": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-axis": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-brush": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "3",
|
||||
"d3-transition": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-chord": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-contour": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-fetch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-polygon": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-random": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1465,6 +1915,27 @@
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
@@ -1949,6 +2420,12 @@
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
|
||||
@@ -1988,6 +2465,12 @@
|
||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
@@ -1998,6 +2481,12 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
|
||||
@@ -15,9 +15,13 @@
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/press-start-2p": "^5.2.7",
|
||||
"@fontsource/vt323": "^5.2.7",
|
||||
"@inertiajs/vue3": "^3.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"d3": "^7.9.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31"
|
||||
}
|
||||
|
||||
@@ -1 +1,29 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource/vt323";
|
||||
@import "@fontsource/press-start-2p";
|
||||
@import "@fontsource/ibm-plex-mono";
|
||||
|
||||
@theme {
|
||||
--color-bg-deep: #1a1a2e;
|
||||
--color-bg-surface: #16213e;
|
||||
--color-primary: #0f3460;
|
||||
--color-accent-cyan: #00d2ff;
|
||||
--color-accent-orange: #e94560;
|
||||
--color-accent-green: #00ff88;
|
||||
--color-accent-purple: #7b68ee;
|
||||
--color-text-primary: #e8e8e8;
|
||||
--color-text-secondary: #8892b0;
|
||||
|
||||
--font-mono: 'IBM Plex Mono', monospace;
|
||||
--font-retro: 'VT323', monospace;
|
||||
--font-pixel: 'Press Start 2P', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-deep);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-mono);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
156
resources/js/Components/Cli/CliBar.vue
Normal file
156
resources/js/Components/Cli/CliBar.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
const emit = defineEmits(['command'])
|
||||
|
||||
const input = ref('')
|
||||
const inputRef = ref(null)
|
||||
const history = ref([])
|
||||
const showHistory = ref(false)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!input.value.trim()) return
|
||||
|
||||
const command = input.value.trim()
|
||||
history.value.push({ type: 'input', text: command })
|
||||
history.value.push({ type: 'response', text: 'Processing...' })
|
||||
showHistory.value = true
|
||||
|
||||
emit('command', command)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const focusInput = () => {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cli-container" @click="focusInput">
|
||||
<!-- History panel -->
|
||||
<Transition name="slide-up">
|
||||
<div v-if="showHistory && history.length > 0" class="cli-history">
|
||||
<div
|
||||
v-for="(entry, i) in history"
|
||||
:key="i"
|
||||
class="history-entry"
|
||||
:class="entry.type"
|
||||
>
|
||||
<span v-if="entry.type === 'input'" class="prompt-char">> </span>
|
||||
<span v-if="entry.type === 'response'" class="ai-label">[AI] </span>
|
||||
{{ entry.text }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Input bar -->
|
||||
<div class="cli-bar">
|
||||
<span class="prompt">></span>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="input"
|
||||
class="cli-input"
|
||||
placeholder="ask me anything..."
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
@keydown.enter="handleSubmit"
|
||||
/>
|
||||
<span class="cursor-blink">█</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cli-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.cli-history {
|
||||
background: rgba(22, 33, 62, 0.95);
|
||||
border-top: 1px solid rgba(0, 210, 255, 0.2);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 12px 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.history-entry {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
.history-entry.input {
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
.history-entry.response {
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.prompt-char {
|
||||
color: #00d2ff;
|
||||
}
|
||||
|
||||
.ai-label {
|
||||
color: #7b68ee;
|
||||
}
|
||||
|
||||
.cli-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #0a0a1a;
|
||||
border-top: 2px solid #00d2ff;
|
||||
padding: 12px 20px;
|
||||
box-shadow: 0 -4px 30px rgba(0, 210, 255, 0.15);
|
||||
}
|
||||
|
||||
.prompt {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 12px;
|
||||
color: #00d2ff;
|
||||
margin-right: 12px;
|
||||
text-shadow: 0 0 10px rgba(0, 210, 255, 0.5);
|
||||
}
|
||||
|
||||
.cli-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
color: #e8e8e8;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.cli-input::placeholder {
|
||||
color: #8892b0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cursor-blink {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
color: #00d2ff;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.slide-up-enter-active, .slide-up-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.slide-up-enter-from, .slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
</style>
|
||||
51
resources/js/Components/MetroMap/Breadcrumb.vue
Normal file
51
resources/js/Components/MetroMap/Breadcrumb.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
path: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['navigate'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="breadcrumb">
|
||||
<span
|
||||
v-for="(item, index) in path"
|
||||
:key="index"
|
||||
class="breadcrumb-item"
|
||||
:class="{ active: index === path.length - 1 }"
|
||||
@click="index < path.length - 1 && emit('navigate', item, index)"
|
||||
>
|
||||
<span v-if="index > 0" class="separator"> > </span>
|
||||
<span :class="{ 'cursor-pointer hover:text-[#00d2ff]': index < path.length - 1 }">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
z-index: 50;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: #8892b0;
|
||||
background: rgba(26, 26, 46, 0.85);
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 210, 255, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.breadcrumb-item.active {
|
||||
color: #00d2ff;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: #8892b0;
|
||||
opacity: 0.5;
|
||||
margin: 0 2px;
|
||||
}
|
||||
</style>
|
||||
354
resources/js/Components/MetroMap/MetroCanvas.vue
Normal file
354
resources/js/Components/MetroMap/MetroCanvas.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, default: () => [] },
|
||||
lines: { type: Array, default: () => [] },
|
||||
connections: { type: Array, default: () => [] },
|
||||
currentLevel: { type: Number, default: 1 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['node-click', 'node-hover', 'node-leave', 'zoom-change'])
|
||||
|
||||
const svgRef = ref(null)
|
||||
const containerRef = ref(null)
|
||||
const transform = ref(d3.zoomIdentity)
|
||||
const hoveredNode = ref(null)
|
||||
|
||||
// Metro line colors
|
||||
const lineColors = [
|
||||
'#00d2ff', '#e94560', '#00ff88', '#7b68ee',
|
||||
'#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff',
|
||||
'#ff8fab', '#a8dadc', '#e07a5f', '#81b29a',
|
||||
]
|
||||
|
||||
const getLineColor = (index) => lineColors[index % lineColors.length]
|
||||
|
||||
let svg, g, zoom
|
||||
|
||||
const initCanvas = () => {
|
||||
if (!svgRef.value) return
|
||||
|
||||
const width = containerRef.value.clientWidth
|
||||
const height = containerRef.value.clientHeight
|
||||
|
||||
svg = d3.select(svgRef.value)
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
|
||||
// Clear previous content
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
// Add defs for glow filter
|
||||
const defs = svg.append('defs')
|
||||
|
||||
const glowFilter = defs.append('filter')
|
||||
.attr('id', 'glow')
|
||||
.attr('x', '-50%')
|
||||
.attr('y', '-50%')
|
||||
.attr('width', '200%')
|
||||
.attr('height', '200%')
|
||||
|
||||
glowFilter.append('feGaussianBlur')
|
||||
.attr('stdDeviation', '4')
|
||||
.attr('result', 'coloredBlur')
|
||||
|
||||
const feMerge = glowFilter.append('feMerge')
|
||||
feMerge.append('feMergeNode').attr('in', 'coloredBlur')
|
||||
feMerge.append('feMergeNode').attr('in', 'SourceGraphic')
|
||||
|
||||
// Scanline pattern
|
||||
const scanlinePattern = defs.append('pattern')
|
||||
.attr('id', 'scanlines')
|
||||
.attr('patternUnits', 'userSpaceOnUse')
|
||||
.attr('width', 4)
|
||||
.attr('height', 4)
|
||||
|
||||
scanlinePattern.append('line')
|
||||
.attr('x1', 0).attr('y1', 0)
|
||||
.attr('x2', 4).attr('y2', 0)
|
||||
.attr('stroke', 'rgba(0,0,0,0.08)')
|
||||
.attr('stroke-width', 1)
|
||||
|
||||
// Main group for zoom/pan
|
||||
g = svg.append('g')
|
||||
|
||||
// Setup zoom
|
||||
zoom = d3.zoom()
|
||||
.scaleExtent([0.3, 5])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform)
|
||||
transform.value = event.transform
|
||||
emit('zoom-change', {
|
||||
scale: event.transform.k,
|
||||
x: event.transform.x,
|
||||
y: event.transform.y,
|
||||
})
|
||||
})
|
||||
|
||||
svg.call(zoom)
|
||||
|
||||
// Center the view
|
||||
const initialTransform = d3.zoomIdentity
|
||||
.translate(width / 2, height / 2)
|
||||
.scale(1)
|
||||
|
||||
svg.call(zoom.transform, initialTransform)
|
||||
|
||||
renderMap()
|
||||
}
|
||||
|
||||
const renderMap = () => {
|
||||
if (!g) return
|
||||
|
||||
g.selectAll('*').remove()
|
||||
|
||||
// Draw metro lines
|
||||
props.lines.forEach((line, lineIndex) => {
|
||||
const color = line.color || getLineColor(lineIndex)
|
||||
const lineNodes = props.nodes.filter(n => n.lineId === line.id)
|
||||
|
||||
if (lineNodes.length < 2) return
|
||||
|
||||
// Sort nodes by order
|
||||
lineNodes.sort((a, b) => a.order - b.order)
|
||||
|
||||
// Draw the line path
|
||||
const lineGenerator = d3.line()
|
||||
.x(d => d.x)
|
||||
.y(d => d.y)
|
||||
.curve(d3.curveMonotoneX)
|
||||
|
||||
g.append('path')
|
||||
.datum(lineNodes)
|
||||
.attr('d', lineGenerator)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', color)
|
||||
.attr('stroke-width', 4)
|
||||
.attr('stroke-linecap', 'round')
|
||||
.attr('opacity', 0.7)
|
||||
|
||||
// Line label
|
||||
if (lineNodes.length > 0) {
|
||||
g.append('text')
|
||||
.attr('x', lineNodes[0].x - 10)
|
||||
.attr('y', lineNodes[0].y - 25)
|
||||
.attr('fill', color)
|
||||
.attr('font-family', "'VT323', monospace")
|
||||
.attr('font-size', '16px')
|
||||
.attr('opacity', 0.8)
|
||||
.text(line.name)
|
||||
}
|
||||
})
|
||||
|
||||
// Draw dependency connections
|
||||
props.connections.forEach(conn => {
|
||||
const source = props.nodes.find(n => n.id === conn.from)
|
||||
const target = props.nodes.find(n => n.id === conn.to)
|
||||
if (!source || !target) return
|
||||
|
||||
g.append('line')
|
||||
.attr('x1', source.x)
|
||||
.attr('y1', source.y)
|
||||
.attr('x2', target.x)
|
||||
.attr('y2', target.y)
|
||||
.attr('stroke', '#8892b0')
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('stroke-dasharray', '6,4')
|
||||
.attr('opacity', 0.4)
|
||||
})
|
||||
|
||||
// Draw station nodes
|
||||
const nodeGroups = g.selectAll('.station')
|
||||
.data(props.nodes)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'station')
|
||||
.attr('transform', d => `translate(${d.x}, ${d.y})`)
|
||||
.attr('cursor', 'pointer')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation()
|
||||
emit('node-click', d)
|
||||
})
|
||||
.on('mouseenter', (event, d) => {
|
||||
hoveredNode.value = d
|
||||
d3.select(event.currentTarget).select('.station-dot')
|
||||
.transition()
|
||||
.duration(200)
|
||||
.attr('r', 12)
|
||||
.attr('filter', 'url(#glow)')
|
||||
emit('node-hover', d)
|
||||
})
|
||||
.on('mouseleave', (event, d) => {
|
||||
hoveredNode.value = null
|
||||
d3.select(event.currentTarget).select('.station-dot')
|
||||
.transition()
|
||||
.duration(200)
|
||||
.attr('r', 8)
|
||||
.attr('filter', null)
|
||||
emit('node-leave', d)
|
||||
})
|
||||
|
||||
// Station outer ring
|
||||
nodeGroups.append('circle')
|
||||
.attr('r', 11)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', d => {
|
||||
const line = props.lines.find(l => l.id === d.lineId)
|
||||
return line?.color || getLineColor(props.lines.indexOf(line))
|
||||
})
|
||||
.attr('stroke-width', 2)
|
||||
|
||||
// Station inner dot
|
||||
nodeGroups.append('circle')
|
||||
.attr('class', 'station-dot')
|
||||
.attr('r', 8)
|
||||
.attr('fill', d => {
|
||||
if (d.status === 'afgerond' || d.status === 'completed') return '#00ff88'
|
||||
if (d.status === 'actief' || d.status === 'active') return '#00d2ff'
|
||||
if (d.status === 'geparkeerd') return '#ffd93d'
|
||||
if (d.status === 'gestopt') return '#e94560'
|
||||
return '#16213e'
|
||||
})
|
||||
.attr('stroke', d => {
|
||||
const line = props.lines.find(l => l.id === d.lineId)
|
||||
return line?.color || getLineColor(props.lines.indexOf(line))
|
||||
})
|
||||
.attr('stroke-width', 2)
|
||||
|
||||
// Station labels
|
||||
nodeGroups.append('text')
|
||||
.attr('x', 18)
|
||||
.attr('y', 5)
|
||||
.attr('fill', '#e8e8e8')
|
||||
.attr('font-family', "'VT323', monospace")
|
||||
.attr('font-size', '14px')
|
||||
.text(d => d.name)
|
||||
|
||||
// Status badge
|
||||
nodeGroups.filter(d => d.badge)
|
||||
.append('text')
|
||||
.attr('x', 18)
|
||||
.attr('y', 20)
|
||||
.attr('fill', '#8892b0')
|
||||
.attr('font-family', "'IBM Plex Mono', monospace")
|
||||
.attr('font-size', '10px')
|
||||
.text(d => d.badge)
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (svgRef.value && containerRef.value) {
|
||||
svg.attr('width', containerRef.value.clientWidth)
|
||||
.attr('height', containerRef.value.clientHeight)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.nodes, props.lines, props.connections], () => {
|
||||
renderMap()
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
// Expose zoom methods
|
||||
const zoomTo = (x, y, scale) => {
|
||||
if (!svg || !zoom) return
|
||||
const transform = d3.zoomIdentity.translate(x, y).scale(scale)
|
||||
svg.transition().duration(500).call(zoom.transform, transform)
|
||||
}
|
||||
|
||||
defineExpose({ zoomTo })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="metro-canvas-container">
|
||||
<svg ref="svgRef" class="metro-canvas"></svg>
|
||||
|
||||
<!-- Hover tooltip -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="hoveredNode"
|
||||
class="node-tooltip"
|
||||
:style="{
|
||||
left: `${hoveredNode.x + transform.x + 30}px`,
|
||||
top: `${hoveredNode.y + transform.y - 20}px`,
|
||||
}"
|
||||
>
|
||||
<div class="tooltip-title">{{ hoveredNode.name }}</div>
|
||||
<div v-if="hoveredNode.description" class="tooltip-desc">{{ hoveredNode.description }}</div>
|
||||
<div v-if="hoveredNode.status" class="tooltip-status">{{ hoveredNode.status }}</div>
|
||||
<div class="tooltip-hint">Click to zoom in</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metro-canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
|
||||
.metro-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.node-tooltip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: #16213e;
|
||||
border: 1px solid #00d2ff;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 0 15px rgba(0, 210, 255, 0.2);
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.tooltip-title {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: #00d2ff;
|
||||
}
|
||||
|
||||
.tooltip-desc {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #8892b0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tooltip-status {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 13px;
|
||||
color: #00ff88;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tooltip-hint {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 11px;
|
||||
color: #8892b0;
|
||||
margin-top: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
147
resources/js/Components/MetroMap/NodePreview.vue
Normal file
147
resources/js/Components/MetroMap/NodePreview.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
node: { type: Object, default: null },
|
||||
visible: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'zoom-in'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="slide">
|
||||
<div v-if="visible && node" class="node-preview">
|
||||
<div class="preview-header">
|
||||
<h2 class="preview-title">{{ node.name }}</h2>
|
||||
<button @click="emit('close')" class="close-btn">[X]</button>
|
||||
</div>
|
||||
|
||||
<div class="preview-meta">
|
||||
<span v-if="node.status" class="status-badge" :class="node.status">
|
||||
{{ node.status }}
|
||||
</span>
|
||||
<span v-if="node.owner" class="owner">{{ node.owner }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="node.description" class="preview-desc">{{ node.description }}</p>
|
||||
|
||||
<div v-if="node.children" class="preview-children">
|
||||
<div class="children-label">Contains {{ node.children }} items</div>
|
||||
</div>
|
||||
|
||||
<button @click="emit('zoom-in', node)" class="zoom-btn">
|
||||
ZOOM IN >>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.node-preview {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 60px;
|
||||
width: 320px;
|
||||
background: #16213e;
|
||||
border: 1px solid rgba(0, 210, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
z-index: 60;
|
||||
box-shadow: 0 0 30px rgba(0, 210, 255, 0.1);
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 22px;
|
||||
color: #00d2ff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
font-family: 'VT323', monospace;
|
||||
color: #8892b0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-badge.actief, .status-badge.active { background: rgba(0, 210, 255, 0.15); color: #00d2ff; }
|
||||
.status-badge.afgerond, .status-badge.completed { background: rgba(0, 255, 136, 0.15); color: #00ff88; }
|
||||
.status-badge.geparkeerd { background: rgba(255, 217, 61, 0.15); color: #ffd93d; }
|
||||
.status-badge.gestopt { background: rgba(233, 69, 96, 0.15); color: #e94560; }
|
||||
|
||||
.owner {
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
.preview-desc {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: #8892b0;
|
||||
margin-top: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-children {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.children-label {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 14px;
|
||||
color: #7b68ee;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: rgba(0, 210, 255, 0.1);
|
||||
border: 1px solid #00d2ff;
|
||||
color: #00d2ff;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.zoom-btn:hover {
|
||||
background: rgba(0, 210, 255, 0.2);
|
||||
box-shadow: 0 0 15px rgba(0, 210, 255, 0.3);
|
||||
}
|
||||
|
||||
.slide-enter-active, .slide-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.slide-enter-from, .slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
346
resources/js/Pages/Auth/ForgotPassword.vue
Normal file
346
resources/js/Pages/Auth/ForgotPassword.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<script setup>
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps({
|
||||
status: String,
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
form.post('/forgot-password')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-screen">
|
||||
<div class="scanlines" />
|
||||
<div class="terminal-frame">
|
||||
<div class="terminal-header">
|
||||
<span class="header-dot red" />
|
||||
<span class="header-dot yellow" />
|
||||
<span class="header-dot green" />
|
||||
<span class="header-title">AUTH.SYS v2.0</span>
|
||||
</div>
|
||||
|
||||
<div class="terminal-body">
|
||||
<div class="prompt-line">
|
||||
<span class="prompt-symbol">></span>
|
||||
<span class="prompt-text">PASSWORD RECOVERY</span>
|
||||
<span class="cursor" />
|
||||
</div>
|
||||
|
||||
<h1 class="page-title">FORGOT<br>PASSWORD</h1>
|
||||
|
||||
<div class="divider">
|
||||
<span>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
</div>
|
||||
|
||||
<p class="info-text">
|
||||
ENTER YOUR EMAIL ADDRESS AND A RECOVERY LINK WILL BE TRANSMITTED.
|
||||
</p>
|
||||
|
||||
<div v-if="status" class="status-msg">
|
||||
<span class="status-prefix">OK:</span> {{ status }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="auth-form">
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="email">
|
||||
<span class="label-prefix">[01]</span> EMAIL_ADDRESS:
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="field-input"
|
||||
:class="{ 'field-error': form.errors.email }"
|
||||
autocomplete="username"
|
||||
placeholder="user@domain.nl"
|
||||
autofocus
|
||||
/>
|
||||
<p v-if="form.errors.email" class="error-msg">
|
||||
<span class="error-prefix">ERR:</span> {{ form.errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-btn"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
<span v-if="form.processing">TRANSMITTING...</span>
|
||||
<span v-else>>> SEND RECOVERY LINK</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">
|
||||
<span>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<Link href="/login" class="auth-link">
|
||||
<< BACK TO LOGIN
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-screen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.08) 2px,
|
||||
rgba(0, 0, 0, 0.08) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.terminal-frame {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 480px;
|
||||
background: #0d0d1a;
|
||||
border: 2px solid #00d2ff;
|
||||
box-shadow:
|
||||
0 0 40px rgba(0, 210, 255, 0.25),
|
||||
inset 0 0 40px rgba(0, 210, 255, 0.03);
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(0, 210, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
|
||||
}
|
||||
|
||||
.header-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-dot.red { background: #e94560; box-shadow: 0 0 6px #e94560; }
|
||||
.header-dot.yellow { background: #f5a623; box-shadow: 0 0 6px #f5a623; }
|
||||
.header-dot.green { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
|
||||
|
||||
.header-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #8892b0;
|
||||
margin-left: auto;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
padding: 32px 40px;
|
||||
}
|
||||
|
||||
.prompt-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prompt-symbol {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 20px;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #8892b0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 16px;
|
||||
background: #00d2ff;
|
||||
animation: blink 1s step-end infinite;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 18px;
|
||||
color: #00d2ff;
|
||||
text-shadow: 0 0 30px rgba(0, 210, 255, 0.5);
|
||||
letter-spacing: 3px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 210, 255, 0.3);
|
||||
margin: 16px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 17px;
|
||||
color: #8892b0;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 20px 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 17px;
|
||||
color: #00ff88;
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-prefix {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
color: #8892b0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.label-prefix {
|
||||
color: #00d2ff;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
background: rgba(0, 210, 255, 0.04);
|
||||
border: 1px solid rgba(0, 210, 255, 0.4);
|
||||
color: #e8e8e8;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-input::placeholder {
|
||||
color: rgba(136, 146, 176, 0.4);
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
border-color: #00d2ff;
|
||||
box-shadow: 0 0 12px rgba(0, 210, 255, 0.2);
|
||||
}
|
||||
|
||||
.field-input.field-error {
|
||||
border-color: #e94560;
|
||||
box-shadow: 0 0 8px rgba(233, 69, 96, 0.2);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: #e94560;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-prefix {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 14px 24px;
|
||||
background: rgba(0, 210, 255, 0.08);
|
||||
border: 2px solid #00d2ff;
|
||||
color: #00d2ff;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 210, 255, 0.18);
|
||||
box-shadow: 0 0 24px rgba(0, 210, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.links {
|
||||
text-align: center;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
color: #8892b0;
|
||||
text-decoration: none;
|
||||
letter-spacing: 1px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.auth-link:hover {
|
||||
color: #00d2ff;
|
||||
text-shadow: 0 0 8px rgba(0, 210, 255, 0.4);
|
||||
}
|
||||
</style>
|
||||
377
resources/js/Pages/Auth/Login.vue
Normal file
377
resources/js/Pages/Auth/Login.vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<script setup>
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
form.post('/login', {
|
||||
onFinish: () => form.reset('password'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-screen">
|
||||
<div class="scanlines" />
|
||||
<div class="terminal-frame">
|
||||
<div class="terminal-header">
|
||||
<span class="header-dot red" />
|
||||
<span class="header-dot yellow" />
|
||||
<span class="header-dot green" />
|
||||
<span class="header-title">AUTH.SYS v2.0</span>
|
||||
</div>
|
||||
|
||||
<div class="terminal-body">
|
||||
<div class="prompt-line">
|
||||
<span class="prompt-symbol">></span>
|
||||
<span class="prompt-text">SYSTEM LOGIN</span>
|
||||
<span class="cursor" />
|
||||
</div>
|
||||
|
||||
<h1 class="page-title">LOGIN</h1>
|
||||
|
||||
<div class="divider">
|
||||
<span>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="auth-form">
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="email">
|
||||
<span class="label-prefix">[01]</span> EMAIL_ADDRESS:
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="field-input"
|
||||
:class="{ 'field-error': form.errors.email }"
|
||||
autocomplete="username"
|
||||
placeholder="user@domain.nl"
|
||||
autofocus
|
||||
/>
|
||||
<p v-if="form.errors.email" class="error-msg">
|
||||
<span class="error-prefix">ERR:</span> {{ form.errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="password">
|
||||
<span class="label-prefix">[02]</span> PASSWORD:
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="field-input"
|
||||
:class="{ 'field-error': form.errors.password }"
|
||||
autocomplete="current-password"
|
||||
placeholder="••••••••••••"
|
||||
/>
|
||||
<p v-if="form.errors.password" class="error-msg">
|
||||
<span class="error-prefix">ERR:</span> {{ form.errors.password }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group remember-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
v-model="form.remember"
|
||||
type="checkbox"
|
||||
class="checkbox-input"
|
||||
/>
|
||||
<span class="checkbox-text">REMEMBER SESSION</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-btn"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
<span v-if="form.processing">AUTHENTICATING...</span>
|
||||
<span v-else>>> AUTHENTICATE</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">
|
||||
<span>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<Link href="/forgot-password" class="auth-link">
|
||||
FORGOT PASSWORD?
|
||||
</Link>
|
||||
<span class="link-separator"> // </span>
|
||||
<Link href="/register" class="auth-link">
|
||||
NEW USER? REGISTER
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-screen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.08) 2px,
|
||||
rgba(0, 0, 0, 0.08) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.terminal-frame {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 480px;
|
||||
background: #0d0d1a;
|
||||
border: 2px solid #00d2ff;
|
||||
box-shadow:
|
||||
0 0 40px rgba(0, 210, 255, 0.25),
|
||||
inset 0 0 40px rgba(0, 210, 255, 0.03);
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(0, 210, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
|
||||
}
|
||||
|
||||
.header-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-dot.red { background: #e94560; box-shadow: 0 0 6px #e94560; }
|
||||
.header-dot.yellow { background: #f5a623; box-shadow: 0 0 6px #f5a623; }
|
||||
.header-dot.green { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
|
||||
|
||||
.header-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #8892b0;
|
||||
margin-left: auto;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
padding: 32px 40px;
|
||||
}
|
||||
|
||||
.prompt-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prompt-symbol {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 20px;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #8892b0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 16px;
|
||||
background: #00d2ff;
|
||||
animation: blink 1s step-end infinite;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 22px;
|
||||
color: #00d2ff;
|
||||
text-shadow: 0 0 30px rgba(0, 210, 255, 0.5);
|
||||
letter-spacing: 4px;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 210, 255, 0.3);
|
||||
margin: 16px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
color: #8892b0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.label-prefix {
|
||||
color: #00d2ff;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
background: rgba(0, 210, 255, 0.04);
|
||||
border: 1px solid rgba(0, 210, 255, 0.4);
|
||||
color: #e8e8e8;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-input::placeholder {
|
||||
color: rgba(136, 146, 176, 0.4);
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
border-color: #00d2ff;
|
||||
box-shadow: 0 0 12px rgba(0, 210, 255, 0.2);
|
||||
}
|
||||
|
||||
.field-input.field-error {
|
||||
border-color: #e94560;
|
||||
box-shadow: 0 0 8px rgba(233, 69, 96, 0.2);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: #e94560;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-prefix {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.remember-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #00d2ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-text {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
color: #8892b0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
margin-top: 8px;
|
||||
padding: 14px 24px;
|
||||
background: rgba(0, 210, 255, 0.08);
|
||||
border: 2px solid #00d2ff;
|
||||
color: #00d2ff;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 210, 255, 0.18);
|
||||
box-shadow: 0 0 24px rgba(0, 210, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.links {
|
||||
text-align: center;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
color: #8892b0;
|
||||
text-decoration: none;
|
||||
letter-spacing: 1px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.auth-link:hover {
|
||||
color: #00d2ff;
|
||||
text-shadow: 0 0 8px rgba(0, 210, 255, 0.4);
|
||||
}
|
||||
|
||||
.link-separator {
|
||||
color: rgba(0, 210, 255, 0.3);
|
||||
margin: 0 4px;
|
||||
}
|
||||
</style>
|
||||
380
resources/js/Pages/Auth/Register.vue
Normal file
380
resources/js/Pages/Auth/Register.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<script setup>
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
form.post('/register', {
|
||||
onFinish: () => form.reset('password', 'password_confirmation'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-screen">
|
||||
<div class="scanlines" />
|
||||
<div class="terminal-frame">
|
||||
<div class="terminal-header">
|
||||
<span class="header-dot red" />
|
||||
<span class="header-dot yellow" />
|
||||
<span class="header-dot green" />
|
||||
<span class="header-title">AUTH.SYS v2.0</span>
|
||||
</div>
|
||||
|
||||
<div class="terminal-body">
|
||||
<div class="prompt-line">
|
||||
<span class="prompt-symbol">></span>
|
||||
<span class="prompt-text">NEW USER REGISTRATION</span>
|
||||
<span class="cursor" />
|
||||
</div>
|
||||
|
||||
<h1 class="page-title">REGISTER</h1>
|
||||
|
||||
<div class="divider">
|
||||
<span>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="auth-form">
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="name">
|
||||
<span class="label-prefix">[01]</span> DISPLAY_NAME:
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="field-input"
|
||||
:class="{ 'field-error': form.errors.name }"
|
||||
autocomplete="name"
|
||||
placeholder="Voornaam Achternaam"
|
||||
autofocus
|
||||
/>
|
||||
<p v-if="form.errors.name" class="error-msg">
|
||||
<span class="error-prefix">ERR:</span> {{ form.errors.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="email">
|
||||
<span class="label-prefix">[02]</span> EMAIL_ADDRESS:
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="field-input"
|
||||
:class="{ 'field-error': form.errors.email }"
|
||||
autocomplete="username"
|
||||
placeholder="user@domain.nl"
|
||||
/>
|
||||
<p v-if="form.errors.email" class="error-msg">
|
||||
<span class="error-prefix">ERR:</span> {{ form.errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="password">
|
||||
<span class="label-prefix">[03]</span> PASSWORD:
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="field-input"
|
||||
:class="{ 'field-error': form.errors.password }"
|
||||
autocomplete="new-password"
|
||||
placeholder="••••••••••••"
|
||||
/>
|
||||
<p v-if="form.errors.password" class="error-msg">
|
||||
<span class="error-prefix">ERR:</span> {{ form.errors.password }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="password_confirmation">
|
||||
<span class="label-prefix">[04]</span> CONFIRM_PASSWORD:
|
||||
</label>
|
||||
<input
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
class="field-input"
|
||||
:class="{ 'field-error': form.errors.password_confirmation }"
|
||||
autocomplete="new-password"
|
||||
placeholder="••••••••••••"
|
||||
/>
|
||||
<p v-if="form.errors.password_confirmation" class="error-msg">
|
||||
<span class="error-prefix">ERR:</span> {{ form.errors.password_confirmation }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-btn"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
<span v-if="form.processing">CREATING ACCOUNT...</span>
|
||||
<span v-else>>> CREATE ACCOUNT</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">
|
||||
<span>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<span class="links-text">ALREADY REGISTERED?</span>
|
||||
<span class="link-separator"> // </span>
|
||||
<Link href="/login" class="auth-link">
|
||||
LOGIN HERE
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-screen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.08) 2px,
|
||||
rgba(0, 0, 0, 0.08) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.terminal-frame {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 480px;
|
||||
background: #0d0d1a;
|
||||
border: 2px solid #00d2ff;
|
||||
box-shadow:
|
||||
0 0 40px rgba(0, 210, 255, 0.25),
|
||||
inset 0 0 40px rgba(0, 210, 255, 0.03);
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(0, 210, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
|
||||
}
|
||||
|
||||
.header-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-dot.red { background: #e94560; box-shadow: 0 0 6px #e94560; }
|
||||
.header-dot.yellow { background: #f5a623; box-shadow: 0 0 6px #f5a623; }
|
||||
.header-dot.green { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
|
||||
|
||||
.header-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #8892b0;
|
||||
margin-left: auto;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
padding: 28px 40px 32px;
|
||||
}
|
||||
|
||||
.prompt-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prompt-symbol {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 20px;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #8892b0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 16px;
|
||||
background: #00d2ff;
|
||||
animation: blink 1s step-end infinite;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 20px;
|
||||
color: #00d2ff;
|
||||
text-shadow: 0 0 30px rgba(0, 210, 255, 0.5);
|
||||
letter-spacing: 4px;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 210, 255, 0.3);
|
||||
margin: 16px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
color: #8892b0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.label-prefix {
|
||||
color: #00d2ff;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
background: rgba(0, 210, 255, 0.04);
|
||||
border: 1px solid rgba(0, 210, 255, 0.4);
|
||||
color: #e8e8e8;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-input::placeholder {
|
||||
color: rgba(136, 146, 176, 0.4);
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
border-color: #00d2ff;
|
||||
box-shadow: 0 0 12px rgba(0, 210, 255, 0.2);
|
||||
}
|
||||
|
||||
.field-input.field-error {
|
||||
border-color: #e94560;
|
||||
box-shadow: 0 0 8px rgba(233, 69, 96, 0.2);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: #e94560;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-prefix {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
margin-top: 8px;
|
||||
padding: 14px 24px;
|
||||
background: rgba(0, 210, 255, 0.08);
|
||||
border: 2px solid #00d2ff;
|
||||
color: #00d2ff;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 210, 255, 0.18);
|
||||
box-shadow: 0 0 24px rgba(0, 210, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.links {
|
||||
text-align: center;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.links-text {
|
||||
color: #8892b0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
color: #8892b0;
|
||||
text-decoration: none;
|
||||
letter-spacing: 1px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.auth-link:hover {
|
||||
color: #00d2ff;
|
||||
text-shadow: 0 0 8px rgba(0, 210, 255, 0.4);
|
||||
}
|
||||
|
||||
.link-separator {
|
||||
color: rgba(0, 210, 255, 0.3);
|
||||
margin: 0 4px;
|
||||
}
|
||||
</style>
|
||||
368
resources/js/Pages/Auth/ResetPassword.vue
Normal file
368
resources/js/Pages/Auth/ResetPassword.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<script setup>
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps({
|
||||
token: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
token: props.token,
|
||||
email: props.email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
form.post('/reset-password', {
|
||||
onFinish: () => form.reset('password', 'password_confirmation'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-screen">
|
||||
<div class="scanlines" />
|
||||
<div class="terminal-frame">
|
||||
<div class="terminal-header">
|
||||
<span class="header-dot red" />
|
||||
<span class="header-dot yellow" />
|
||||
<span class="header-dot green" />
|
||||
<span class="header-title">AUTH.SYS v2.0</span>
|
||||
</div>
|
||||
|
||||
<div class="terminal-body">
|
||||
<div class="prompt-line">
|
||||
<span class="prompt-symbol">></span>
|
||||
<span class="prompt-text">RESET CREDENTIALS</span>
|
||||
<span class="cursor" />
|
||||
</div>
|
||||
|
||||
<h1 class="page-title">RESET<br>PASSWORD</h1>
|
||||
|
||||
<div class="divider">
|
||||
<span>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="auth-form">
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="email">
|
||||
<span class="label-prefix">[01]</span> EMAIL_ADDRESS:
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="field-input"
|
||||
:class="{ 'field-error': form.errors.email }"
|
||||
autocomplete="username"
|
||||
readonly
|
||||
/>
|
||||
<p v-if="form.errors.email" class="error-msg">
|
||||
<span class="error-prefix">ERR:</span> {{ form.errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="password">
|
||||
<span class="label-prefix">[02]</span> NEW_PASSWORD:
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="field-input"
|
||||
:class="{ 'field-error': form.errors.password }"
|
||||
autocomplete="new-password"
|
||||
placeholder="••••••••••••"
|
||||
autofocus
|
||||
/>
|
||||
<p v-if="form.errors.password" class="error-msg">
|
||||
<span class="error-prefix">ERR:</span> {{ form.errors.password }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="password_confirmation">
|
||||
<span class="label-prefix">[03]</span> CONFIRM_PASSWORD:
|
||||
</label>
|
||||
<input
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
class="field-input"
|
||||
:class="{ 'field-error': form.errors.password_confirmation }"
|
||||
autocomplete="new-password"
|
||||
placeholder="••••••••••••"
|
||||
/>
|
||||
<p v-if="form.errors.password_confirmation" class="error-msg">
|
||||
<span class="error-prefix">ERR:</span> {{ form.errors.password_confirmation }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-btn"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
<span v-if="form.processing">UPDATING...</span>
|
||||
<span v-else>>> RESET PASSWORD</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">
|
||||
<span>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<Link href="/login" class="auth-link">
|
||||
<< BACK TO LOGIN
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-screen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.08) 2px,
|
||||
rgba(0, 0, 0, 0.08) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.terminal-frame {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 480px;
|
||||
background: #0d0d1a;
|
||||
border: 2px solid #00d2ff;
|
||||
box-shadow:
|
||||
0 0 40px rgba(0, 210, 255, 0.25),
|
||||
inset 0 0 40px rgba(0, 210, 255, 0.03);
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(0, 210, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
|
||||
}
|
||||
|
||||
.header-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-dot.red { background: #e94560; box-shadow: 0 0 6px #e94560; }
|
||||
.header-dot.yellow { background: #f5a623; box-shadow: 0 0 6px #f5a623; }
|
||||
.header-dot.green { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
|
||||
|
||||
.header-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #8892b0;
|
||||
margin-left: auto;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
padding: 32px 40px;
|
||||
}
|
||||
|
||||
.prompt-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prompt-symbol {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 20px;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #8892b0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 16px;
|
||||
background: #00d2ff;
|
||||
animation: blink 1s step-end infinite;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 18px;
|
||||
color: #00d2ff;
|
||||
text-shadow: 0 0 30px rgba(0, 210, 255, 0.5);
|
||||
letter-spacing: 3px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 210, 255, 0.3);
|
||||
margin: 16px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
color: #8892b0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.label-prefix {
|
||||
color: #00d2ff;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
background: rgba(0, 210, 255, 0.04);
|
||||
border: 1px solid rgba(0, 210, 255, 0.4);
|
||||
color: #e8e8e8;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-input[readonly] {
|
||||
color: #8892b0;
|
||||
border-color: rgba(0, 210, 255, 0.2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.field-input::placeholder {
|
||||
color: rgba(136, 146, 176, 0.4);
|
||||
}
|
||||
|
||||
.field-input:focus:not([readonly]) {
|
||||
border-color: #00d2ff;
|
||||
box-shadow: 0 0 12px rgba(0, 210, 255, 0.2);
|
||||
}
|
||||
|
||||
.field-input.field-error {
|
||||
border-color: #e94560;
|
||||
box-shadow: 0 0 8px rgba(233, 69, 96, 0.2);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: #e94560;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-prefix {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
margin-top: 8px;
|
||||
padding: 14px 24px;
|
||||
background: rgba(0, 210, 255, 0.08);
|
||||
border: 2px solid #00d2ff;
|
||||
color: #00d2ff;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 210, 255, 0.18);
|
||||
box-shadow: 0 0 24px rgba(0, 210, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.links {
|
||||
text-align: center;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
color: #8892b0;
|
||||
text-decoration: none;
|
||||
letter-spacing: 1px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.auth-link:hover {
|
||||
color: #00d2ff;
|
||||
text-shadow: 0 0 8px rgba(0, 210, 255, 0.4);
|
||||
}
|
||||
</style>
|
||||
344
resources/js/Pages/Auth/VerifyEmail.vue
Normal file
344
resources/js/Pages/Auth/VerifyEmail.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<script setup>
|
||||
import { useForm, router } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps({
|
||||
status: String,
|
||||
})
|
||||
|
||||
const form = useForm({})
|
||||
|
||||
const resend = () => {
|
||||
form.post('/email/verification-notification')
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
router.post('/logout')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-screen">
|
||||
<div class="scanlines" />
|
||||
<div class="terminal-frame">
|
||||
<div class="terminal-header">
|
||||
<span class="header-dot red" />
|
||||
<span class="header-dot yellow" />
|
||||
<span class="header-dot green" />
|
||||
<span class="header-title">AUTH.SYS v2.0</span>
|
||||
</div>
|
||||
|
||||
<div class="terminal-body">
|
||||
<div class="prompt-line">
|
||||
<span class="prompt-symbol">></span>
|
||||
<span class="prompt-text">EMAIL VERIFICATION</span>
|
||||
<span class="cursor" />
|
||||
</div>
|
||||
|
||||
<h1 class="page-title">VERIFY<br>EMAIL</h1>
|
||||
|
||||
<div class="divider">
|
||||
<span>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
</div>
|
||||
|
||||
<div class="info-block">
|
||||
<div class="info-line">
|
||||
<span class="info-icon">[!]</span>
|
||||
<p class="info-text">
|
||||
ACCOUNT ACTIVATION REQUIRED. A VERIFICATION LINK HAS BEEN TRANSMITTED TO YOUR EMAIL ADDRESS.
|
||||
</p>
|
||||
</div>
|
||||
<p class="info-sub">
|
||||
CHECK YOUR INBOX AND CLICK THE LINK TO ACTIVATE YOUR ACCOUNT.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="status === 'verification-link-sent'" class="status-msg">
|
||||
<span class="status-prefix">OK:</span> NEW VERIFICATION LINK TRANSMITTED.
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
@click="resend"
|
||||
class="submit-btn"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
<span v-if="form.processing">TRANSMITTING...</span>
|
||||
<span v-else>>> RESEND VERIFICATION</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="logout"
|
||||
class="logout-btn"
|
||||
>
|
||||
LOGOUT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider">
|
||||
<span>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</span>
|
||||
</div>
|
||||
|
||||
<div class="status-line">
|
||||
<span class="status-label">SESSION STATUS:</span>
|
||||
<span class="status-value blink-slow">AWAITING VERIFICATION</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-screen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.08) 2px,
|
||||
rgba(0, 0, 0, 0.08) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.terminal-frame {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 480px;
|
||||
background: #0d0d1a;
|
||||
border: 2px solid #00d2ff;
|
||||
box-shadow:
|
||||
0 0 40px rgba(0, 210, 255, 0.25),
|
||||
inset 0 0 40px rgba(0, 210, 255, 0.03);
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(0, 210, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
|
||||
}
|
||||
|
||||
.header-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-dot.red { background: #e94560; box-shadow: 0 0 6px #e94560; }
|
||||
.header-dot.yellow { background: #f5a623; box-shadow: 0 0 6px #f5a623; }
|
||||
.header-dot.green { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
|
||||
|
||||
.header-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #8892b0;
|
||||
margin-left: auto;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
padding: 32px 40px;
|
||||
}
|
||||
|
||||
.prompt-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prompt-symbol {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 20px;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #8892b0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 16px;
|
||||
background: #00d2ff;
|
||||
animation: blink 1s step-end infinite;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 18px;
|
||||
color: #00d2ff;
|
||||
text-shadow: 0 0 30px rgba(0, 210, 255, 0.5);
|
||||
letter-spacing: 3px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 210, 255, 0.3);
|
||||
margin: 16px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-line {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: #f5a623;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
color: #e8e8e8;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-sub {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 16px;
|
||||
color: #8892b0;
|
||||
margin: 0;
|
||||
padding-left: 28px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 17px;
|
||||
color: #00ff88;
|
||||
background: rgba(0, 255, 136, 0.08);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-prefix {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 14px 24px;
|
||||
background: rgba(0, 210, 255, 0.08);
|
||||
border: 2px solid #00d2ff;
|
||||
color: #00d2ff;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 210, 255, 0.18);
|
||||
box-shadow: 0 0 24px rgba(0, 210, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 10px 24px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(233, 69, 96, 0.4);
|
||||
color: #8892b0;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
letter-spacing: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
border-color: #e94560;
|
||||
color: #e94560;
|
||||
background: rgba(233, 69, 96, 0.06);
|
||||
}
|
||||
|
||||
.status-line {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #8892b0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
color: #f5a623;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.blink-slow {
|
||||
animation: blink-slow 2s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-slow {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,90 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
import { ref } from 'vue'
|
||||
import { router, usePage } from '@inertiajs/vue3'
|
||||
import CliBar from '@/Components/Cli/CliBar.vue'
|
||||
|
||||
const page = usePage()
|
||||
const user = page.props.auth?.user
|
||||
|
||||
// Redirect to map (the map IS the dashboard)
|
||||
const goToMap = () => router.visit('/map')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p class="mt-2 text-gray-600">Welkom bij het Innovatieplatform van het R&D Lab.</p>
|
||||
</AppLayout>
|
||||
<div class="dashboard">
|
||||
<div class="welcome">
|
||||
<h1 class="title">INNOVATIEPLATFORM</h1>
|
||||
<p class="subtitle">R&D Lab — Waterschap Brabantse Delta</p>
|
||||
|
||||
<div class="user-greeting" v-if="user">
|
||||
<span class="greeting-text">Welkom, {{ user.name }}</span>
|
||||
</div>
|
||||
|
||||
<button @click="goToMap" class="enter-btn">
|
||||
OPEN METRO MAP >>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CliBar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a1a2e;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 24px;
|
||||
color: #00d2ff;
|
||||
text-shadow: 0 0 30px rgba(0, 210, 255, 0.4);
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 20px;
|
||||
color: #8892b0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.user-greeting {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.greeting-text {
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.enter-btn {
|
||||
margin-top: 40px;
|
||||
padding: 14px 32px;
|
||||
background: rgba(0, 210, 255, 0.1);
|
||||
border: 2px solid #00d2ff;
|
||||
color: #00d2ff;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.enter-btn:hover {
|
||||
background: rgba(0, 210, 255, 0.2);
|
||||
box-shadow: 0 0 30px rgba(0, 210, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
|
||||
127
resources/js/Pages/Map/MetroMap.vue
Normal file
127
resources/js/Pages/Map/MetroMap.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import MetroCanvas from '@/Components/MetroMap/MetroCanvas.vue'
|
||||
import Breadcrumb from '@/Components/MetroMap/Breadcrumb.vue'
|
||||
import NodePreview from '@/Components/MetroMap/NodePreview.vue'
|
||||
import CliBar from '@/Components/Cli/CliBar.vue'
|
||||
|
||||
const page = usePage()
|
||||
|
||||
// Navigation state
|
||||
const currentLevel = ref(1)
|
||||
const breadcrumbPath = ref([{ label: 'Strategie', level: 1, data: null }])
|
||||
const selectedNode = ref(null)
|
||||
const showPreview = ref(false)
|
||||
|
||||
// Demo data for Level 1: Strategy map
|
||||
// Each theme is a metro line, projects are stations
|
||||
const demoLines = ref([
|
||||
{ id: 'water-quality', name: 'Waterkwaliteit', color: '#00d2ff' },
|
||||
{ id: 'smart-infra', name: 'Slimme Infrastructuur', color: '#e94560' },
|
||||
{ id: 'data-driven', name: 'Data-gedreven Beheer', color: '#00ff88' },
|
||||
{ id: 'sustainability', name: 'Duurzaamheid', color: '#7b68ee' },
|
||||
])
|
||||
|
||||
const demoNodes = ref([
|
||||
// Water Quality line
|
||||
{ id: 'wq1', name: 'Smart Sensors', lineId: 'water-quality', x: -200, y: -150, order: 1, status: 'actief', description: 'Slimme sensoren voor waterkwaliteitsmonitoring', owner: 'Jan Visser', children: 5, badge: 'Experiment' },
|
||||
{ id: 'wq2', name: 'Biomonitoring', lineId: 'water-quality', x: 0, y: -150, order: 2, status: 'concept', description: 'Biologische monitoring methoden', owner: 'Sara Jansen', children: 3, badge: 'Concept' },
|
||||
{ id: 'wq3', name: 'Voorspelmodel', lineId: 'water-quality', x: 200, y: -150, order: 3, status: 'signaal', description: 'Predictief model waterkwaliteit', badge: 'Signaal' },
|
||||
|
||||
// Smart Infrastructure line
|
||||
{ id: 'si1', name: 'Edge Computing', lineId: 'smart-infra', x: -200, y: -20, order: 1, status: 'actief', description: 'Edge-layer evolutie voor OT-omgevingen', owner: 'Rene de Ren', children: 8, badge: 'Pilot' },
|
||||
{ id: 'si2', name: 'Digital Twin', lineId: 'smart-infra', x: 50, y: -20, order: 2, status: 'verkenning', description: 'Digitale tweeling van zuiveringsinstallaties', children: 2, badge: 'Verkenning' },
|
||||
{ id: 'si3', name: 'Predictive Maint.', lineId: 'smart-infra', x: 250, y: -20, order: 3, status: 'concept', description: 'Voorspellend onderhoud op basis van sensordata', badge: 'Concept' },
|
||||
|
||||
// Data-driven line
|
||||
{ id: 'dd1', name: 'Data Platform', lineId: 'data-driven', x: -150, y: 110, order: 1, status: 'afgerond', description: 'Centraal dataplatform voor operationele data', owner: 'Lisa de Boer', badge: 'Afgerond' },
|
||||
{ id: 'dd2', name: 'ML Pipeline', lineId: 'data-driven', x: 80, y: 110, order: 2, status: 'actief', description: 'Machine learning pipeline voor anomalie-detectie', owner: 'Tom Bakker', children: 4, badge: 'Experiment' },
|
||||
{ id: 'dd3', name: 'Dashboard Suite', lineId: 'data-driven', x: 280, y: 110, order: 3, status: 'overdracht_bouwen', description: 'Operationele dashboards voor beheerders', badge: 'Overdracht' },
|
||||
|
||||
// Sustainability line
|
||||
{ id: 'su1', name: 'Energietransitie', lineId: 'sustainability', x: -100, y: 240, order: 1, status: 'pilot', description: 'Energieneutraal zuiveren', owner: 'Mark de Vries', children: 6, badge: 'Pilot' },
|
||||
{ id: 'su2', name: 'Circulair Water', lineId: 'sustainability', x: 150, y: 240, order: 2, status: 'verkenning', description: 'Circulaire waterbehandeling', badge: 'Verkenning' },
|
||||
])
|
||||
|
||||
const demoConnections = ref([
|
||||
{ from: 'wq1', to: 'dd2' }, // Smart sensors feeds ML Pipeline
|
||||
{ from: 'si1', to: 'dd1' }, // Edge computing depends on data platform
|
||||
{ from: 'dd2', to: 'si3' }, // ML pipeline enables predictive maintenance
|
||||
])
|
||||
|
||||
// Handlers
|
||||
const handleNodeClick = (node) => {
|
||||
selectedNode.value = node
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
const handleNodeHover = (node) => {
|
||||
// Could highlight related nodes/connections
|
||||
}
|
||||
|
||||
const handleNodeLeave = () => {
|
||||
// Reset highlights
|
||||
}
|
||||
|
||||
const handleZoomIn = (node) => {
|
||||
showPreview.value = false
|
||||
currentLevel.value++
|
||||
breadcrumbPath.value.push({
|
||||
label: node.name,
|
||||
level: currentLevel.value,
|
||||
data: node,
|
||||
})
|
||||
// In real app: load child nodes for this project
|
||||
}
|
||||
|
||||
const handleBreadcrumbNavigate = (item, index) => {
|
||||
breadcrumbPath.value = breadcrumbPath.value.slice(0, index + 1)
|
||||
currentLevel.value = item.level
|
||||
showPreview.value = false
|
||||
selectedNode.value = null
|
||||
}
|
||||
|
||||
const handleCliCommand = (command) => {
|
||||
console.log('CLI command:', command)
|
||||
// Will be connected to AI service
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="metro-map-page">
|
||||
<Breadcrumb
|
||||
:path="breadcrumbPath"
|
||||
@navigate="handleBreadcrumbNavigate"
|
||||
/>
|
||||
|
||||
<MetroCanvas
|
||||
:nodes="demoNodes"
|
||||
:lines="demoLines"
|
||||
:connections="demoConnections"
|
||||
:current-level="currentLevel"
|
||||
@node-click="handleNodeClick"
|
||||
@node-hover="handleNodeHover"
|
||||
@node-leave="handleNodeLeave"
|
||||
/>
|
||||
|
||||
<NodePreview
|
||||
:node="selectedNode"
|
||||
:visible="showPreview"
|
||||
@close="showPreview = false"
|
||||
@zoom-in="handleZoomIn"
|
||||
/>
|
||||
|
||||
<CliBar @command="handleCliCommand" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metro-map-page {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
</style>
|
||||
8
routes/api.php
Normal file
8
routes/api.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/user', function (Request $request) {
|
||||
return $request->user();
|
||||
})->middleware('auth:sanctum');
|
||||
@@ -1,12 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\MapController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\Controllers\ThemaController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
||||
Route::get('/', function () {
|
||||
return Inertia::render('Dashboard');
|
||||
});
|
||||
// Redirect root to map
|
||||
Route::get('/', fn () => redirect('/map'));
|
||||
|
||||
Route::get('/dashboard', function () {
|
||||
return Inertia::render('Dashboard');
|
||||
// Authenticated routes
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
// Metro Map
|
||||
Route::get('/map', [MapController::class, 'index'])->name('map');
|
||||
Route::get('/map/project/{project}', [MapController::class, 'project'])->name('map.project');
|
||||
|
||||
// API endpoints for dynamic map data
|
||||
Route::prefix('api')->group(function () {
|
||||
Route::get('/map/strategy', [MapController::class, 'apiStrategy'])->name('api.map.strategy');
|
||||
Route::get('/map/project/{project}', [MapController::class, 'apiProject'])->name('api.map.project');
|
||||
});
|
||||
|
||||
// Projects
|
||||
Route::post('/projects', [ProjectController::class, 'store'])->name('projects.store');
|
||||
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
|
||||
Route::put('/projects/{project}', [ProjectController::class, 'update'])->name('projects.update');
|
||||
Route::post('/projects/{project}/transition', [ProjectController::class, 'transition'])->name('projects.transition');
|
||||
Route::post('/projects/{project}/park', [ProjectController::class, 'park'])->name('projects.park');
|
||||
Route::post('/projects/{project}/stop', [ProjectController::class, 'stop'])->name('projects.stop');
|
||||
Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])->name('projects.destroy');
|
||||
|
||||
// Themas
|
||||
Route::get('/themas', [ThemaController::class, 'index'])->name('themas.index');
|
||||
Route::post('/themas', [ThemaController::class, 'store'])->name('themas.store');
|
||||
Route::put('/themas/{thema}', [ThemaController::class, 'update'])->name('themas.update');
|
||||
|
||||
// Dashboard (redirects to map)
|
||||
Route::get('/dashboard', fn () => redirect('/map'))->name('dashboard');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user