feat(portal): auth persistence, shift visibility, profile page, and UI polish
- Fix session persistence: add loading state to App.vue, hydrate portal store in router guards so page refresh preserves auth + event context - Fix shift visibility for festivals: query child event time slots so shifts on sub-events appear in the portal - Add profile page with editable personal info and password change - Add backend endpoints: PUT /portal/profile and PUT /portal/password - Fix registration form: make first_name/last_name editable for logged-in users - Restyle login page: remove Vuexy illustration, center form with Crewli branding - Improve dashboard StatusCard with action cards, icons, and upcoming shift count - Enhance shift cards with status border colors and availability progress bars Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,10 +31,12 @@ final class PortalShiftController extends Controller
|
||||
return $this->forbidden('Je moet eerst goedgekeurd zijn om diensten te claimen.');
|
||||
}
|
||||
|
||||
$eventIds = $this->resolveEventIds($event);
|
||||
|
||||
$shifts = Shift::query()
|
||||
->where('status', 'open')
|
||||
->where('slots_open_for_claiming', '>', 0)
|
||||
->whereHas('timeSlot', fn ($q) => $q->where('event_id', $event->id)->where('person_type', 'VOLUNTEER'))
|
||||
->whereHas('timeSlot', fn ($q) => $q->whereIn('event_id', $eventIds)->where('person_type', 'VOLUNTEER'))
|
||||
->with(['festivalSection', 'timeSlot', 'location'])
|
||||
->withCount([
|
||||
'shiftAssignments as active_assignments_count' => fn ($q) => $q->whereNotIn('status', [
|
||||
@@ -113,8 +115,10 @@ final class PortalShiftController extends Controller
|
||||
{
|
||||
$person = $this->resolvePerson($event);
|
||||
|
||||
$eventIds = $this->resolveEventIds($event);
|
||||
|
||||
$assignments = ShiftAssignment::where('person_id', $person->id)
|
||||
->whereHas('shift.timeSlot', fn ($q) => $q->where('event_id', $event->id))
|
||||
->whereHas('shift.timeSlot', fn ($q) => $q->whereIn('event_id', $eventIds))
|
||||
->with(['shift.festivalSection', 'shift.timeSlot', 'shift.location'])
|
||||
->get();
|
||||
|
||||
@@ -239,6 +243,25 @@ final class PortalShiftController extends Controller
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all event IDs relevant for shift queries.
|
||||
* For festivals: includes parent + all child event IDs.
|
||||
* For flat/sub-events: just the event's own ID.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveEventIds(Event $event): array
|
||||
{
|
||||
$ids = [$event->id];
|
||||
|
||||
if ($event->isFestival()) {
|
||||
$childIds = $event->children()->pluck('id')->all();
|
||||
$ids = array_merge($ids, $childIds);
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function mapClaimErrorMessage(string $message): string
|
||||
{
|
||||
if (str_contains($message, 'niet open')) {
|
||||
|
||||
@@ -12,6 +12,10 @@ use App\Models\Event;
|
||||
use App\Models\Person;
|
||||
use App\Models\TimeSlot;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
final class PortalMeController extends Controller
|
||||
{
|
||||
@@ -77,4 +81,56 @@ final class PortalMeController extends Controller
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
public function updateProfile(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'event_id' => ['required', 'ulid'],
|
||||
'first_name' => ['sometimes', 'string', 'max:255'],
|
||||
'last_name' => ['sometimes', 'string', 'max:255'],
|
||||
'phone' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'date_of_birth' => ['sometimes', 'nullable', 'date', 'before:today'],
|
||||
'remarks' => ['sometimes', 'nullable', 'string', 'max:5000'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$event = Event::findOrFail($validated['event_id']);
|
||||
|
||||
if ($event->isSubEvent()) {
|
||||
$event = $event->parent;
|
||||
}
|
||||
|
||||
$person = Person::where('user_id', $user->id)
|
||||
->where('event_id', $event->id)
|
||||
->firstOrFail();
|
||||
|
||||
// Update user record (name fields)
|
||||
$userFields = Arr::only($validated, ['first_name', 'last_name']);
|
||||
if (!empty($userFields)) {
|
||||
$user->update($userFields);
|
||||
}
|
||||
|
||||
// Update person record (phone, date_of_birth, remarks)
|
||||
$personFields = Arr::only($validated, ['first_name', 'last_name', 'phone', 'date_of_birth', 'remarks']);
|
||||
if (!empty($personFields)) {
|
||||
$person->update($personFields);
|
||||
}
|
||||
|
||||
return $this->success(['message' => 'Profiel bijgewerkt.']);
|
||||
}
|
||||
|
||||
public function updatePassword(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'current_password' => ['required', 'string', 'current_password'],
|
||||
'password' => ['required', 'string', Password::min(8), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return $this->success(['message' => 'Wachtwoord gewijzigd.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ Route::middleware('auth:sanctum')->group(function () {
|
||||
|
||||
// Portal (authenticated)
|
||||
Route::get('portal/me', [PortalMeController::class, 'index']);
|
||||
Route::put('portal/profile', [PortalMeController::class, 'updateProfile']);
|
||||
Route::put('portal/password', [PortalMeController::class, 'updatePassword']);
|
||||
Route::get('portal/events/{event}/available-shifts', [PortalShiftController::class, 'availableShifts']);
|
||||
Route::get('portal/events/{event}/my-shifts', [PortalShiftController::class, 'myShifts']);
|
||||
Route::post('portal/events/{event}/shifts/{shift}/claim', [PortalShiftController::class, 'claim']);
|
||||
|
||||
196
api/tests/Feature/Api/V1/Portal/PortalProfileTest.php
Normal file
196
api/tests/Feature/Api/V1/Portal/PortalProfileTest.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\V1\Portal;
|
||||
|
||||
use App\Models\CrowdType;
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalProfileTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $volunteer;
|
||||
private Organisation $organisation;
|
||||
private Event $event;
|
||||
private Person $person;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
|
||||
$this->organisation = Organisation::factory()->create();
|
||||
$this->volunteer = User::factory()->create([
|
||||
'first_name' => 'Jan',
|
||||
'last_name' => 'Jansen',
|
||||
'password' => Hash::make('old-password'),
|
||||
]);
|
||||
$this->organisation->users()->attach($this->volunteer, ['role' => 'org_member']);
|
||||
|
||||
$this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
$crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
$this->person = Person::factory()->approved()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'crowd_type_id' => $crowdType->id,
|
||||
'user_id' => $this->volunteer->id,
|
||||
'first_name' => 'Jan',
|
||||
'last_name' => 'Jansen',
|
||||
'phone' => '0612345678',
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Profile update
|
||||
// =========================================================================
|
||||
|
||||
public function test_update_profile_updates_user_and_person(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/profile', [
|
||||
'event_id' => $this->event->id,
|
||||
'first_name' => 'Piet',
|
||||
'last_name' => 'Pietersen',
|
||||
'phone' => '0687654321',
|
||||
'date_of_birth' => '1990-05-15',
|
||||
'remarks' => 'Vegetarisch',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.message', 'Profiel bijgewerkt.');
|
||||
|
||||
$this->volunteer->refresh();
|
||||
$this->assertEquals('Piet', $this->volunteer->first_name);
|
||||
$this->assertEquals('Pietersen', $this->volunteer->last_name);
|
||||
|
||||
$this->person->refresh();
|
||||
$this->assertEquals('Piet', $this->person->first_name);
|
||||
$this->assertEquals('Pietersen', $this->person->last_name);
|
||||
$this->assertEquals('0687654321', $this->person->phone);
|
||||
$this->assertEquals('1990-05-15', $this->person->date_of_birth->toDateString());
|
||||
$this->assertEquals('Vegetarisch', $this->person->remarks);
|
||||
}
|
||||
|
||||
public function test_update_profile_partial_update(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/profile', [
|
||||
'event_id' => $this->event->id,
|
||||
'phone' => '0699999999',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->person->refresh();
|
||||
$this->assertEquals('0699999999', $this->person->phone);
|
||||
$this->assertEquals('Jan', $this->person->first_name); // unchanged
|
||||
}
|
||||
|
||||
public function test_update_profile_requires_event_id(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/profile', [
|
||||
'first_name' => 'Piet',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
}
|
||||
|
||||
public function test_update_profile_unauthenticated(): void
|
||||
{
|
||||
$response = $this->putJson('/api/v1/portal/profile', [
|
||||
'event_id' => $this->event->id,
|
||||
'first_name' => 'Piet',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Password update
|
||||
// =========================================================================
|
||||
|
||||
public function test_update_password(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/password', [
|
||||
'current_password' => 'old-password',
|
||||
'password' => 'new-secure-password',
|
||||
'password_confirmation' => 'new-secure-password',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.message', 'Wachtwoord gewijzigd.');
|
||||
|
||||
$this->volunteer->refresh();
|
||||
$this->assertTrue(Hash::check('new-secure-password', $this->volunteer->password));
|
||||
}
|
||||
|
||||
public function test_update_password_wrong_current(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/password', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-secure-password',
|
||||
'password_confirmation' => 'new-secure-password',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['current_password']);
|
||||
}
|
||||
|
||||
public function test_update_password_mismatch(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/password', [
|
||||
'current_password' => 'old-password',
|
||||
'password' => 'new-secure-password',
|
||||
'password_confirmation' => 'different-password',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['password']);
|
||||
}
|
||||
|
||||
public function test_update_password_too_short(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/password', [
|
||||
'current_password' => 'old-password',
|
||||
'password' => 'short',
|
||||
'password_confirmation' => 'short',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['password']);
|
||||
}
|
||||
|
||||
public function test_update_password_unauthenticated(): void
|
||||
{
|
||||
$response = $this->putJson('/api/v1/portal/password', [
|
||||
'current_password' => 'old-password',
|
||||
'password' => 'new-secure-password',
|
||||
'password_confirmation' => 'new-secure-password',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,89 @@ class PortalShiftClaimingTest extends TestCase
|
||||
$this->assertEquals('Open Shift', $allShifts->first()['title']);
|
||||
}
|
||||
|
||||
public function test_available_shifts_includes_sub_event_shifts_for_festival(): void
|
||||
{
|
||||
// Create a festival parent with a sub-event
|
||||
$festival = Event::factory()->festival()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
$subEvent = Event::factory()->subEvent($festival)->create();
|
||||
|
||||
// Person is registered at the festival level
|
||||
$person = Person::factory()->approved()->create([
|
||||
'event_id' => $festival->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'user_id' => $this->volunteer->id,
|
||||
]);
|
||||
|
||||
// Shift lives on the sub-event
|
||||
$subSection = FestivalSection::factory()->create(['event_id' => $subEvent->id]);
|
||||
$subSlot = TimeSlot::factory()->create([
|
||||
'event_id' => $subEvent->id,
|
||||
'person_type' => 'VOLUNTEER',
|
||||
'date' => now()->addMonth(),
|
||||
]);
|
||||
$shift = Shift::factory()->open()->create([
|
||||
'festival_section_id' => $subSection->id,
|
||||
'time_slot_id' => $subSlot->id,
|
||||
'slots_total' => 4,
|
||||
'slots_open_for_claiming' => 3,
|
||||
'title' => 'Sub-event Shift',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->getJson("/api/v1/portal/events/{$festival->id}/available-shifts");
|
||||
|
||||
$response->assertOk();
|
||||
$allShifts = collect($response->json('data'))
|
||||
->flatMap(fn ($day) => collect($day['time_slots']))
|
||||
->flatMap(fn ($ts) => $ts['shifts']);
|
||||
|
||||
$this->assertCount(1, $allShifts);
|
||||
$this->assertEquals('Sub-event Shift', $allShifts->first()['title']);
|
||||
}
|
||||
|
||||
public function test_my_shifts_includes_sub_event_assignments_for_festival(): void
|
||||
{
|
||||
$festival = Event::factory()->festival()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
$subEvent = Event::factory()->subEvent($festival)->create();
|
||||
|
||||
$person = Person::factory()->approved()->create([
|
||||
'event_id' => $festival->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'user_id' => $this->volunteer->id,
|
||||
]);
|
||||
|
||||
$subSection = FestivalSection::factory()->create(['event_id' => $subEvent->id]);
|
||||
$subSlot = TimeSlot::factory()->create([
|
||||
'event_id' => $subEvent->id,
|
||||
'person_type' => 'VOLUNTEER',
|
||||
'date' => now()->addMonth(),
|
||||
]);
|
||||
$shift = Shift::factory()->open()->create([
|
||||
'festival_section_id' => $subSection->id,
|
||||
'time_slot_id' => $subSlot->id,
|
||||
'slots_total' => 4,
|
||||
'slots_open_for_claiming' => 3,
|
||||
]);
|
||||
|
||||
ShiftAssignment::factory()->approved()->create([
|
||||
'shift_id' => $shift->id,
|
||||
'person_id' => $person->id,
|
||||
'time_slot_id' => $subSlot->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->getJson("/api/v1/portal/events/{$festival->id}/my-shifts");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(1, $response->json('data.upcoming'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// My shifts
|
||||
// =========================================================================
|
||||
|
||||
@@ -4,16 +4,32 @@ import ScrollToTop from '@core/components/ScrollToTop.vue'
|
||||
import initCore from '@core/initCore'
|
||||
import { initConfigStore } from '@core/stores/config'
|
||||
import { hexToRgb } from '@core/utils/colorConverter'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
|
||||
const { global } = useTheme()
|
||||
|
||||
initCore()
|
||||
initConfigStore()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Validate stored token on app startup — must complete before rendering protected content
|
||||
authStore.initialize()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VApp :style="`--v-global-theme-primary: ${hexToRgb(global.current.value.colors.primary)}`">
|
||||
<RouterView />
|
||||
<ScrollToTop />
|
||||
<!-- Show loading state while validating auth token -->
|
||||
<template v-if="!authStore.isInitialized">
|
||||
<div class="d-flex align-center justify-center" style="min-height: 100vh;">
|
||||
<VProgressCircular indeterminate color="primary" size="48" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Only render app shell after auth is resolved -->
|
||||
<template v-else>
|
||||
<RouterView />
|
||||
<ScrollToTop />
|
||||
</template>
|
||||
</VApp>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,8 @@ const props = defineProps<{
|
||||
eventName: string
|
||||
registeredAt?: string | null
|
||||
nextShiftSummary?: string | null
|
||||
upcomingCount?: number
|
||||
availableCount?: number | null
|
||||
}>()
|
||||
|
||||
const registeredLabel = computed(() => {
|
||||
@@ -88,6 +90,7 @@ const registeredLabel = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick action cards -->
|
||||
<VRow class="mb-6">
|
||||
<VCol
|
||||
cols="12"
|
||||
@@ -95,15 +98,23 @@ const registeredLabel = computed(() => {
|
||||
>
|
||||
<VCard
|
||||
:to="{ name: 'portal-my-shifts' }"
|
||||
variant="outlined"
|
||||
class="pa-4 h-100 text-decoration-none"
|
||||
class="h-100 text-decoration-none portal-action-card"
|
||||
elevation="1"
|
||||
>
|
||||
<div class="text-subtitle-2 text-medium-emphasis mb-1">
|
||||
Mijn Diensten
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
Rooster bekijken
|
||||
</div>
|
||||
<VCardText class="d-flex flex-column align-center text-center pa-4">
|
||||
<VIcon
|
||||
icon="tabler-calendar-check"
|
||||
size="28"
|
||||
color="primary"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div class="text-subtitle-2 font-weight-bold mb-1">
|
||||
Mijn Diensten
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
Rooster bekijken
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -112,15 +123,23 @@ const registeredLabel = computed(() => {
|
||||
>
|
||||
<VCard
|
||||
:to="{ name: 'portal-claim-shifts' }"
|
||||
variant="outlined"
|
||||
class="pa-4 h-100 text-decoration-none"
|
||||
class="h-100 text-decoration-none portal-action-card"
|
||||
elevation="1"
|
||||
>
|
||||
<div class="text-subtitle-2 text-medium-emphasis mb-1">
|
||||
Diensten claimen
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
Schrijf je in voor diensten
|
||||
</div>
|
||||
<VCardText class="d-flex flex-column align-center text-center pa-4">
|
||||
<VIcon
|
||||
icon="tabler-calendar-plus"
|
||||
size="28"
|
||||
color="primary"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div class="text-subtitle-2 font-weight-bold mb-1">
|
||||
Diensten Claimen
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
Schrijf je in
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -129,21 +148,30 @@ const registeredLabel = computed(() => {
|
||||
>
|
||||
<VCard
|
||||
:to="{ name: 'portal-profile' }"
|
||||
variant="outlined"
|
||||
class="pa-4 h-100 text-decoration-none"
|
||||
class="h-100 text-decoration-none portal-action-card"
|
||||
elevation="1"
|
||||
>
|
||||
<div class="text-subtitle-2 text-medium-emphasis mb-1">
|
||||
Profiel
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
Gegevens bekijken
|
||||
</div>
|
||||
<VCardText class="d-flex flex-column align-center text-center pa-4">
|
||||
<VIcon
|
||||
icon="tabler-user"
|
||||
size="28"
|
||||
color="primary"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div class="text-subtitle-2 font-weight-bold mb-1">
|
||||
Mijn Profiel
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
Gegevens bekijken
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- Upcoming shift -->
|
||||
<div class="text-subtitle-1 font-weight-bold mb-2">
|
||||
Komende shift
|
||||
Komende dienst
|
||||
</div>
|
||||
<p
|
||||
v-if="nextShiftSummary"
|
||||
@@ -155,8 +183,57 @@ const registeredLabel = computed(() => {
|
||||
v-else
|
||||
class="text-body-2 text-medium-emphasis mb-0"
|
||||
>
|
||||
Er is nog geen shift ingepland. Je coördinator houdt je op de hoogte.
|
||||
Nog geen diensten ingepland.
|
||||
<RouterLink
|
||||
:to="{ name: 'portal-claim-shifts' }"
|
||||
class="text-primary font-weight-medium"
|
||||
>
|
||||
Diensten claimen
|
||||
</RouterLink>
|
||||
</p>
|
||||
|
||||
<!-- Quick stats -->
|
||||
<div
|
||||
v-if="upcomingCount !== undefined || availableCount !== null"
|
||||
class="d-flex flex-wrap gap-4 mt-4 pt-4"
|
||||
style="border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));"
|
||||
>
|
||||
<div
|
||||
v-if="upcomingCount !== undefined"
|
||||
class="text-body-2"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-calendar-check"
|
||||
size="16"
|
||||
class="me-1"
|
||||
/>
|
||||
Diensten ingepland: <strong>{{ upcomingCount }}</strong>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-if="availableCount !== null && availableCount !== undefined"
|
||||
:to="{ name: 'portal-claim-shifts' }"
|
||||
class="text-body-2 text-primary text-decoration-none"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-calendar-plus"
|
||||
size="16"
|
||||
class="me-1"
|
||||
/>
|
||||
Beschikbare diensten bekijken
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.portal-action-card {
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.portal-action-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
47
apps/portal/src/composables/api/usePortalProfile.ts
Normal file
47
apps/portal/src/composables/api/usePortalProfile.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface ProfileUpdatePayload {
|
||||
event_id: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
phone?: string | null
|
||||
date_of_birth?: string | null
|
||||
remarks?: string | null
|
||||
}
|
||||
|
||||
export interface PasswordUpdatePayload {
|
||||
current_password: string
|
||||
password: string
|
||||
password_confirmation: string
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
return useMutation({
|
||||
mutationFn: async (payload: ProfileUpdatePayload) => {
|
||||
const { data } = await apiClient.put<ApiResponse<{ message: string }>>(
|
||||
'/portal/profile',
|
||||
payload,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdatePassword() {
|
||||
return useMutation({
|
||||
mutationFn: async (payload: PasswordUpdatePayload) => {
|
||||
const { data } = await apiClient.put<ApiResponse<{ message: string }>>(
|
||||
'/portal/password',
|
||||
payload,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -66,11 +66,7 @@ function availabilityColor(slotsAvailable: number): string {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!portal.activeEventId) {
|
||||
await portal.hydrateAfterAuth()
|
||||
}
|
||||
})
|
||||
// Portal hydration now happens automatically in the router guard
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -142,17 +138,28 @@ onMounted(async () => {
|
||||
:key="day.date"
|
||||
class="mb-6"
|
||||
>
|
||||
<h5 class="text-h5 mb-3">
|
||||
{{ day.date_label }}
|
||||
</h5>
|
||||
<div class="d-flex align-center gap-2 mb-3">
|
||||
<VIcon
|
||||
icon="tabler-calendar"
|
||||
size="20"
|
||||
color="primary"
|
||||
/>
|
||||
<h5 class="text-h5 mb-0">
|
||||
{{ day.date_label }}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="slot in day.time_slots"
|
||||
:key="slot.time_slot_id"
|
||||
class="mb-4"
|
||||
>
|
||||
<div class="text-subtitle-1 font-weight-medium text-medium-emphasis mb-2">
|
||||
{{ slot.name }} ({{ slot.start_time }} - {{ slot.end_time }})
|
||||
<div class="d-flex align-center gap-2 text-subtitle-1 font-weight-medium text-medium-emphasis mb-2">
|
||||
<VIcon
|
||||
icon="tabler-clock"
|
||||
size="16"
|
||||
/>
|
||||
{{ slot.name }} · {{ slot.start_time }} - {{ slot.end_time }}
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
@@ -165,7 +172,8 @@ onMounted(async () => {
|
||||
>
|
||||
<VCard
|
||||
variant="outlined"
|
||||
class="h-100"
|
||||
class="h-100 claim-card"
|
||||
:class="{ 'claim-card--conflict': shift.has_conflict }"
|
||||
>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
@@ -173,49 +181,57 @@ onMounted(async () => {
|
||||
v-if="shift.section_icon"
|
||||
:icon="shift.section_icon"
|
||||
size="24"
|
||||
color="primary"
|
||||
:color="shift.has_conflict ? 'disabled' : 'primary'"
|
||||
/>
|
||||
</template>
|
||||
<VCardTitle class="text-subtitle-1 font-weight-bold">
|
||||
{{ shift.title }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>{{ shift.section_name }}</VCardSubtitle>
|
||||
<template #append>
|
||||
<VChip
|
||||
:color="availabilityColor(shift.slots_available)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ shift.slots_available }}/{{ shift.slots_open_for_claiming }}
|
||||
</VChip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText class="pt-0">
|
||||
<div
|
||||
v-if="shift.location_name"
|
||||
class="text-body-2 mb-1"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-map-pin"
|
||||
size="14"
|
||||
class="me-1"
|
||||
/>
|
||||
{{ shift.location_name }}
|
||||
<div class="d-flex flex-wrap gap-x-4 gap-y-1 text-body-2 mb-2">
|
||||
<span v-if="shift.location_name">
|
||||
<VIcon
|
||||
icon="tabler-map-pin"
|
||||
size="14"
|
||||
class="me-1"
|
||||
/>
|
||||
{{ shift.location_name }}
|
||||
</span>
|
||||
<span v-if="shift.report_time">
|
||||
<VIcon
|
||||
icon="tabler-clock"
|
||||
size="14"
|
||||
class="me-1"
|
||||
/>
|
||||
Aanwezig: {{ shift.report_time }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="shift.report_time"
|
||||
class="text-body-2 mb-1"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-clock"
|
||||
size="14"
|
||||
class="me-1"
|
||||
<!-- Availability progress -->
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-space-between text-caption text-medium-emphasis mb-1">
|
||||
<span>{{ shift.slots_available }} van {{ shift.slots_open_for_claiming }} plekken beschikbaar</span>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
:model-value="((shift.slots_open_for_claiming - shift.slots_available) / shift.slots_open_for_claiming) * 100"
|
||||
:color="availabilityColor(shift.slots_available)"
|
||||
height="6"
|
||||
rounded
|
||||
/>
|
||||
Aanwezig: {{ shift.report_time }}
|
||||
</div>
|
||||
|
||||
<VChip
|
||||
:color="availabilityColor(shift.slots_available)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="mt-2 mb-2"
|
||||
>
|
||||
{{ shift.slots_available }} van {{ shift.slots_open_for_claiming }} plekken beschikbaar
|
||||
</VChip>
|
||||
|
||||
<div
|
||||
v-if="shift.description"
|
||||
class="mt-2"
|
||||
@@ -252,15 +268,21 @@ onMounted(async () => {
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-alert-triangle"
|
||||
size="18"
|
||||
/>
|
||||
</template>
|
||||
{{ shift.conflict_reason }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
block
|
||||
:variant="shift.has_conflict ? 'tonal' : 'elevated'"
|
||||
:disabled="shift.has_conflict || claimMutation.isPending.value"
|
||||
:loading="claimMutation.isPending.value && selectedShift?.id === shift.id"
|
||||
@click="openClaimDialog(shift, day.date_label, slot.start_time, slot.end_time)"
|
||||
@@ -328,3 +350,9 @@ onMounted(async () => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.claim-card--conflict {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import StatusCard from '@/components/portal/StatusCard.vue'
|
||||
import { usePortalStore } from '@/stores/usePortalStore'
|
||||
import { useMyShifts } from '@/composables/api/usePortalShifts'
|
||||
import type { PortalPersonPayload } from '@/types/portal'
|
||||
|
||||
definePage({
|
||||
@@ -12,6 +13,10 @@ definePage({
|
||||
})
|
||||
|
||||
const portal = usePortalStore()
|
||||
const eventId = computed(() => portal.activeEventId)
|
||||
|
||||
// Fetch my shifts to show upcoming count
|
||||
const { data: shifts } = useMyShifts(eventId)
|
||||
|
||||
const effectiveStatus = computed(() => {
|
||||
const fromPerson = portal.currentPerson?.status
|
||||
@@ -32,6 +37,8 @@ const eventTitle = computed(() => portal.activeEvent?.event_name ?? 'dit eveneme
|
||||
|
||||
const registeredAt = computed(() => portal.currentPerson?.created_at ?? null)
|
||||
|
||||
const upcomingCount = computed(() => shifts.value?.upcoming.length ?? 0)
|
||||
|
||||
function formatNextShift(person: PortalPersonPayload | null): string | null {
|
||||
const list = person?.shift_assignments
|
||||
if (!list?.length) return null
|
||||
@@ -64,14 +71,12 @@ function formatNextShift(person: PortalPersonPayload | null): string | null {
|
||||
|
||||
const place = section ? ` — ${section}` : ''
|
||||
|
||||
return `📅 ${dateStr}${timePart ? `, ${timePart}` : ''}${place}`
|
||||
return `${dateStr}${timePart ? `, ${timePart}` : ''}${place}`
|
||||
}
|
||||
|
||||
const nextShiftSummary = computed(() => formatNextShift(portal.currentPerson))
|
||||
|
||||
onMounted(async () => {
|
||||
await portal.hydrateAfterAuth()
|
||||
})
|
||||
// Portal hydration now happens automatically in the router guard
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -126,6 +131,7 @@ onMounted(async () => {
|
||||
:event-name="eventTitle"
|
||||
:registered-at="registeredAt"
|
||||
:next-shift-summary="nextShiftSummary"
|
||||
:upcoming-count="upcomingCount"
|
||||
/>
|
||||
</template>
|
||||
</VCol>
|
||||
|
||||
@@ -58,11 +58,7 @@ async function confirmCancel() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!portal.activeEventId) {
|
||||
await portal.hydrateAfterAuth()
|
||||
}
|
||||
})
|
||||
// Portal hydration now happens automatically in the router guard
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -130,7 +126,8 @@ onMounted(async () => {
|
||||
v-for="assignment in shifts.upcoming"
|
||||
:key="assignment.assignment_id"
|
||||
variant="outlined"
|
||||
class="mb-3"
|
||||
class="mb-3 shift-card"
|
||||
:class="`shift-card--${assignment.status}`"
|
||||
>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
@@ -419,3 +416,29 @@ onMounted(async () => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shift-card {
|
||||
border-inline-start: 3px solid transparent;
|
||||
}
|
||||
|
||||
.shift-card--approved {
|
||||
border-inline-start-color: rgb(var(--v-theme-success));
|
||||
}
|
||||
|
||||
.shift-card--pending_approval {
|
||||
border-inline-start-color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.shift-card--rejected {
|
||||
border-inline-start-color: rgb(var(--v-theme-error));
|
||||
}
|
||||
|
||||
.shift-card--cancelled {
|
||||
border-inline-start-color: rgb(var(--v-theme-secondary));
|
||||
}
|
||||
|
||||
.shift-card--completed {
|
||||
border-inline-start-color: rgb(var(--v-theme-info));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant'
|
||||
import authV2LoginIllustrationLight from '@images/pages/auth-v2-login-illustration-light.png'
|
||||
import authV2LoginIllustrationDark from '@images/pages/auth-v2-login-illustration-dark.png'
|
||||
import authV2LoginIllustrationBorderedLight from '@images/pages/auth-v2-login-illustration-bordered-light.png'
|
||||
import authV2LoginIllustrationBorderedDark from '@images/pages/auth-v2-login-illustration-bordered-dark.png'
|
||||
import miscMaskLight from '@images/pages/misc-mask-light.png'
|
||||
import miscMaskDark from '@images/pages/misc-mask-dark.png'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { usePortalStore } from '@/stores/usePortalStore'
|
||||
|
||||
@@ -33,16 +26,6 @@ const isSubmitting = ref(false)
|
||||
|
||||
const passwordResetDone = computed(() => route.query.reset === '1')
|
||||
|
||||
const authThemeImg = useGenerateImageVariant(
|
||||
authV2LoginIllustrationLight,
|
||||
authV2LoginIllustrationDark,
|
||||
authV2LoginIllustrationBorderedLight,
|
||||
authV2LoginIllustrationBorderedDark,
|
||||
true,
|
||||
)
|
||||
|
||||
const authThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark)
|
||||
|
||||
function mapLoginErrorMessage(message: string | undefined): string {
|
||||
if (!message) return 'Inloggen mislukt. Controleer je gegevens.'
|
||||
if (message === 'Invalid credentials' || message.toLowerCase().includes('invalid credentials'))
|
||||
@@ -84,161 +67,120 @@ async function onSubmit(): Promise<void> {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/">
|
||||
<div class="auth-logo d-flex align-center gap-x-3">
|
||||
<VIcon
|
||||
icon="tabler-users-group"
|
||||
size="28"
|
||||
color="primary"
|
||||
/>
|
||||
<h1 class="auth-title">
|
||||
Crewli
|
||||
</h1>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<VRow
|
||||
no-gutters
|
||||
class="auth-wrapper bg-surface"
|
||||
>
|
||||
<!-- Left: Illustration -->
|
||||
<VCol
|
||||
md="8"
|
||||
class="d-none d-md-flex"
|
||||
<div class="login-page d-flex align-center justify-center" style="min-height: 100vh; background: #f5f5f5;">
|
||||
<VCard
|
||||
:max-width="440"
|
||||
class="pa-6 pa-sm-8"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<div class="position-relative bg-background w-100 me-0">
|
||||
<div
|
||||
class="d-flex align-center justify-center w-100 h-100"
|
||||
style="padding-inline: 6.25rem;"
|
||||
<!-- Crewli branding -->
|
||||
<div class="text-center mb-6">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="d-inline-flex align-center gap-x-2 text-decoration-none"
|
||||
>
|
||||
<VImg
|
||||
max-width="613"
|
||||
:src="authThemeImg"
|
||||
class="auth-illustration mt-16 mb-2"
|
||||
<VIcon
|
||||
icon="tabler-users-group"
|
||||
size="32"
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<img
|
||||
class="auth-footer-mask flip-in-rtl"
|
||||
:src="authThemeMask"
|
||||
alt="auth-footer-mask"
|
||||
height="280"
|
||||
width="100"
|
||||
>
|
||||
<span class="text-h4 font-weight-bold text-high-emphasis">
|
||||
Crewli
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<!-- Right: Login form -->
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
class="auth-card-v2 d-flex align-center justify-center"
|
||||
>
|
||||
<VCard
|
||||
flat
|
||||
:max-width="500"
|
||||
class="mt-12 mt-sm-0 pa-6"
|
||||
>
|
||||
<VCardText>
|
||||
<h4 class="text-h4 mb-1">
|
||||
Welkom terug!
|
||||
</h4>
|
||||
<p class="mb-0">
|
||||
Log in om je rooster en shifts te bekijken
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardText class="pa-0">
|
||||
<h4 class="text-h5 mb-1">
|
||||
Welkom terug!
|
||||
</h4>
|
||||
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||
Log in om je rooster en diensten te bekijken
|
||||
</p>
|
||||
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="passwordResetDone"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="comfortable"
|
||||
>
|
||||
Wachtwoord gewijzigd. Je kunt nu inloggen.
|
||||
</VAlert>
|
||||
<VAlert
|
||||
v-if="passwordResetDone"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="comfortable"
|
||||
>
|
||||
Wachtwoord gewijzigd. Je kunt nu inloggen.
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="comfortable"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</VAlert>
|
||||
<VAlert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="comfortable"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</VAlert>
|
||||
|
||||
<VForm @submit.prevent="onSubmit">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="form.email"
|
||||
autofocus
|
||||
label="E-mailadres"
|
||||
type="email"
|
||||
placeholder="je@email.nl"
|
||||
autocomplete="email"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VForm @submit.prevent="onSubmit">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="form.email"
|
||||
autofocus
|
||||
label="E-mailadres"
|
||||
type="email"
|
||||
placeholder="je@email.nl"
|
||||
autocomplete="email"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="form.password"
|
||||
label="Wachtwoord"
|
||||
placeholder="Je wachtwoord"
|
||||
autocomplete="current-password"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
required
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
/>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="form.password"
|
||||
label="Wachtwoord"
|
||||
placeholder="Je wachtwoord"
|
||||
autocomplete="current-password"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
required
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center flex-wrap justify-end my-4">
|
||||
<RouterLink
|
||||
to="/wachtwoord-vergeten"
|
||||
class="text-primary text-body-2"
|
||||
>
|
||||
Wachtwoord vergeten?
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="isSubmitting"
|
||||
<div class="d-flex align-center flex-wrap justify-end my-4">
|
||||
<RouterLink
|
||||
to="/wachtwoord-vergeten"
|
||||
class="text-primary text-body-2"
|
||||
>
|
||||
Inloggen
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
Wachtwoord vergeten?
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<p class="text-body-2 text-center text-medium-emphasis mt-6 mb-0">
|
||||
Nog geen account?
|
||||
<RouterLink
|
||||
to="/registreren"
|
||||
class="text-primary font-weight-medium"
|
||||
>
|
||||
Meld je aan als vrijwilliger
|
||||
</RouterLink>
|
||||
→
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VBtn
|
||||
block
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="isSubmitting"
|
||||
>
|
||||
Inloggen
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
|
||||
<p class="text-body-2 text-center text-medium-emphasis mt-6 mb-0">
|
||||
Nog geen account?
|
||||
<RouterLink
|
||||
to="/registreren"
|
||||
class="text-primary font-weight-medium"
|
||||
>
|
||||
Meld je aan als vrijwilliger
|
||||
</RouterLink>
|
||||
</p>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@core/scss/template/pages/page-auth";
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { usePortalStore } from '@/stores/usePortalStore'
|
||||
import { useUpdateProfile, useUpdatePassword } from '@/composables/api/usePortalProfile'
|
||||
|
||||
definePage({
|
||||
name: 'portal-profile',
|
||||
meta: {
|
||||
@@ -6,6 +10,130 @@ definePage({
|
||||
requiresAuth: true,
|
||||
},
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const portal = usePortalStore()
|
||||
|
||||
const updateProfileMutation = useUpdateProfile()
|
||||
const updatePasswordMutation = useUpdatePassword()
|
||||
|
||||
const snackbar = ref(false)
|
||||
const snackbarMessage = ref('')
|
||||
const snackbarColor = ref('success')
|
||||
|
||||
// Profile form
|
||||
const profileForm = ref({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
date_of_birth: '',
|
||||
remarks: '',
|
||||
})
|
||||
const profileError = ref<string | null>(null)
|
||||
|
||||
// Password form
|
||||
const passwordForm = ref({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
const passwordError = ref<string | null>(null)
|
||||
const passwordFieldErrors = ref<Record<string, string>>({})
|
||||
const showCurrentPassword = ref(false)
|
||||
const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
|
||||
// Populate profile form from current person data
|
||||
watch(() => portal.currentPerson, (person) => {
|
||||
if (person) {
|
||||
profileForm.value = {
|
||||
first_name: person.first_name ?? authStore.user?.first_name ?? '',
|
||||
last_name: person.last_name ?? authStore.user?.last_name ?? '',
|
||||
phone: person.phone ?? '',
|
||||
date_of_birth: person.date_of_birth ?? '',
|
||||
remarks: person.remarks ?? '',
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: 'In afwachting', color: 'warning' },
|
||||
approved: { label: 'Goedgekeurd', color: 'success' },
|
||||
rejected: { label: 'Afgewezen', color: 'error' },
|
||||
}
|
||||
|
||||
const effectiveStatus = computed(() => {
|
||||
const s = portal.currentPerson?.status ?? portal.activeEvent?.person_status ?? 'pending'
|
||||
|
||||
return statusConfig[s] ?? statusConfig.pending
|
||||
})
|
||||
|
||||
async function saveProfile() {
|
||||
profileError.value = null
|
||||
|
||||
if (!portal.activeEventId) return
|
||||
|
||||
try {
|
||||
const result = await updateProfileMutation.mutateAsync({
|
||||
event_id: portal.activeEventId,
|
||||
...profileForm.value,
|
||||
phone: profileForm.value.phone || null,
|
||||
date_of_birth: profileForm.value.date_of_birth || null,
|
||||
remarks: profileForm.value.remarks || null,
|
||||
})
|
||||
|
||||
// Refresh person data and auth user
|
||||
await Promise.all([
|
||||
portal.fetchCurrentPerson(),
|
||||
authStore.fetchUser(),
|
||||
])
|
||||
|
||||
snackbarMessage.value = result.message
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
}
|
||||
catch (err: any) {
|
||||
const data = err?.response?.data
|
||||
if (data?.errors) {
|
||||
const firstError = Object.values(data.errors).flat()[0] as string
|
||||
profileError.value = firstError
|
||||
}
|
||||
else {
|
||||
profileError.value = data?.message ?? 'Er is een fout opgetreden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
passwordError.value = null
|
||||
passwordFieldErrors.value = {}
|
||||
|
||||
try {
|
||||
const result = await updatePasswordMutation.mutateAsync(passwordForm.value)
|
||||
|
||||
passwordForm.value = {
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
}
|
||||
|
||||
snackbarMessage.value = result.message
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
}
|
||||
catch (err: any) {
|
||||
const data = err?.response?.data
|
||||
if (data?.errors) {
|
||||
passwordFieldErrors.value = {}
|
||||
for (const [key, messages] of Object.entries(data.errors)) {
|
||||
passwordFieldErrors.value[key] = (messages as string[])[0]
|
||||
}
|
||||
}
|
||||
else {
|
||||
passwordError.value = data?.message ?? 'Er is een fout opgetreden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -15,14 +143,247 @@ definePage({
|
||||
md="8"
|
||||
lg="6"
|
||||
>
|
||||
<VCard class="text-center pa-6">
|
||||
<VCardTitle class="text-h5">
|
||||
Mijn Profiel
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
Beheer je persoonlijke gegevens
|
||||
</VCardSubtitle>
|
||||
</VCard>
|
||||
<h4 class="text-h4 mb-4">
|
||||
Mijn Profiel
|
||||
</h4>
|
||||
|
||||
<!-- Loading state -->
|
||||
<VSkeletonLoader
|
||||
v-if="portal.isLoadingPerson && !portal.currentPerson"
|
||||
type="card"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<template v-else-if="portal.currentPerson">
|
||||
<!-- Status & Event info -->
|
||||
<VCard class="mb-4">
|
||||
<VCardText>
|
||||
<div class="d-flex align-center justify-space-between flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Evenement
|
||||
</div>
|
||||
<div class="text-body-1 font-weight-medium">
|
||||
{{ portal.activeEvent?.event_name }}
|
||||
</div>
|
||||
</div>
|
||||
<VChip
|
||||
:color="effectiveStatus.color"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ effectiveStatus.label }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Profile form -->
|
||||
<VCard class="mb-4">
|
||||
<VCardTitle>Persoonlijke gegevens</VCardTitle>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="profileError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ profileError }}
|
||||
</VAlert>
|
||||
|
||||
<VForm @submit.prevent="saveProfile">
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.first_name"
|
||||
label="Voornaam"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.last_name"
|
||||
label="Achternaam"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
:model-value="portal.currentPerson.email || authStore.user?.email"
|
||||
label="E-mailadres"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
readonly
|
||||
prepend-inner-icon="tabler-lock"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mt-1 mb-0">
|
||||
Je e-mailadres kan niet worden gewijzigd.
|
||||
</p>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.phone"
|
||||
label="Telefoonnummer"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
prepend-inner-icon="tabler-phone"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.date_of_birth"
|
||||
label="Geboortedatum"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
type="date"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VIcon
|
||||
icon="tabler-calendar"
|
||||
size="20"
|
||||
/>
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="profileForm.remarks"
|
||||
label="Opmerkingen"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
rows="3"
|
||||
placeholder="Allergieën, dieetwensen, overige opmerkingen..."
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div class="d-flex justify-end mt-4">
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="updateProfileMutation.isPending.value"
|
||||
>
|
||||
Opslaan
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Password change -->
|
||||
<VCard>
|
||||
<VCardTitle>Wachtwoord wijzigen</VCardTitle>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="passwordError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ passwordError }}
|
||||
</VAlert>
|
||||
|
||||
<VForm @submit.prevent="savePassword">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="passwordForm.current_password"
|
||||
label="Huidig wachtwoord"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="showCurrentPassword ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
:error-messages="passwordFieldErrors.current_password"
|
||||
@click:append-inner="showCurrentPassword = !showCurrentPassword"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="passwordForm.password"
|
||||
label="Nieuw wachtwoord"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="showNewPassword ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
:error-messages="passwordFieldErrors.password"
|
||||
@click:append-inner="showNewPassword = !showNewPassword"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="passwordForm.password_confirmation"
|
||||
label="Bevestig nieuw wachtwoord"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="showConfirmPassword ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
:error-messages="passwordFieldErrors.password_confirmation"
|
||||
@click:append-inner="showConfirmPassword = !showConfirmPassword"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div class="d-flex justify-end mt-4">
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="updatePasswordMutation.isPending.value"
|
||||
>
|
||||
Wachtwoord wijzigen
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<VAlert
|
||||
v-else
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
>
|
||||
We konden je profiel niet laden. Probeer het later opnieuw.
|
||||
</VAlert>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<VSnackbar
|
||||
v-model="snackbar"
|
||||
:color="snackbarColor"
|
||||
:timeout="4000"
|
||||
>
|
||||
{{ snackbarMessage }}
|
||||
</VSnackbar>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
|
||||
@@ -998,7 +998,6 @@ async function onSubmit() {
|
||||
variant="outlined"
|
||||
placeholder="Je voornaam"
|
||||
:error-messages="errors.first_name"
|
||||
:disabled="authStore.isAuthenticated"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
@@ -1020,7 +1019,6 @@ async function onSubmit() {
|
||||
variant="outlined"
|
||||
placeholder="Je achternaam"
|
||||
:error-messages="errors.last_name"
|
||||
:disabled="authStore.isAuthenticated"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { usePortalStore } from '@/stores/usePortalStore'
|
||||
|
||||
const guestOnlyPaths = ['/login', '/wachtwoord-vergeten', '/wachtwoord-resetten']
|
||||
|
||||
@@ -11,6 +12,12 @@ export function setupGuards(router: Router) {
|
||||
await authStore.initialize()
|
||||
}
|
||||
|
||||
// Hydrate portal data once after auth is confirmed
|
||||
if (authStore.isAuthenticated) {
|
||||
const portalStore = usePortalStore()
|
||||
await portalStore.hydrateIfNeeded()
|
||||
}
|
||||
|
||||
const requiresAuth = to.meta.requiresAuth === true
|
||||
|
||||
// Public routes — no auth check needed
|
||||
|
||||
@@ -162,10 +162,26 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const isHydrated = ref(false)
|
||||
let hydratePromise: Promise<void> | null = null
|
||||
|
||||
async function hydrateAfterAuth(): Promise<void> {
|
||||
await loadUserEventsFromApiAndStorage()
|
||||
resolveActiveEventId()
|
||||
await fetchCurrentPerson()
|
||||
isHydrated.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate portal data if not already done. Safe to call multiple times —
|
||||
* only the first call triggers the actual hydration.
|
||||
*/
|
||||
function hydrateIfNeeded(): Promise<void> {
|
||||
if (isHydrated.value) return Promise.resolve()
|
||||
if (!hydratePromise)
|
||||
hydratePromise = hydrateAfterAuth().finally(() => { hydratePromise = null })
|
||||
|
||||
return hydratePromise
|
||||
}
|
||||
|
||||
function setActiveEvent(eventId: string): void {
|
||||
@@ -180,6 +196,8 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
userEvents.value = []
|
||||
currentPerson.value = null
|
||||
loadError.value = null
|
||||
isHydrated.value = false
|
||||
hydratePromise = null
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(STORAGE_EVENTS)
|
||||
localStorage.removeItem(STORAGE_ACTIVE_EVENT)
|
||||
@@ -193,9 +211,11 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
activeEvent,
|
||||
isLoadingEvents,
|
||||
isLoadingPerson,
|
||||
isHydrated,
|
||||
loadError,
|
||||
savePendingEventFromRegistration,
|
||||
hydrateAfterAuth,
|
||||
hydrateIfNeeded,
|
||||
setActiveEvent,
|
||||
fetchCurrentPerson,
|
||||
reset,
|
||||
|
||||
@@ -44,7 +44,13 @@ export interface PortalPersonPayload {
|
||||
id: string
|
||||
event_id: string
|
||||
status: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
full_name: string
|
||||
email: string
|
||||
phone: string | null
|
||||
date_of_birth: string | null
|
||||
remarks: string | null
|
||||
created_at: string
|
||||
shift_assignments?: Array<{
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user