From c732be2dcf8bf4561c6a67b2f1af54df100022ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Wed, 18 Feb 2026 22:33:33 +0100 Subject: [PATCH] feat: Add reservations list UI and related updates Introduce a full ReservationList feature: new standalone component (TS), template (HTML) and styles (SCSS) with signal-based state, filtering, loading/error handling, name lookups (forkJoin), and actions for deleting/canceling reservations. Add UserService.getById to support guest lookups. Update topbar navigation to expose Reservations to both GUEST and HOST roles. Minor tweaks: add box-shadow to accommodation card SCSS and use `void` when navigating to accommodation detail to satisfy linting/TS expectations. --- src/app/core/services/user.service.ts | 4 + .../reservation-list.component.html | 147 +++++++++++ .../reservation-list.component.scss | 242 ++++++++++++++++++ .../reservation/reservation-list.component.ts | 209 ++++++++++++++- src/app/layouts/topbar/topbar.component.ts | 2 +- .../accommodation-card.component.scss | 1 + .../accommodation-card.component.ts | 2 +- 7 files changed, 591 insertions(+), 16 deletions(-) create mode 100644 src/app/features/reservation/reservation-list.component.html create mode 100644 src/app/features/reservation/reservation-list.component.scss diff --git a/src/app/core/services/user.service.ts b/src/app/core/services/user.service.ts index 7427532..c2e3ea4 100644 --- a/src/app/core/services/user.service.ts +++ b/src/app/core/services/user.service.ts @@ -16,6 +16,10 @@ export class UserService { return this.api.get('/user/me'); } + getById(id: string): Observable { + return this.api.get(`/user/${id}`); + } + updateProfile(data: UpdateProfileRequest): Observable { return this.api.put('/user/me', data).pipe( tap(response => this.authService.handleAuthResponse(response)) diff --git a/src/app/features/reservation/reservation-list.component.html b/src/app/features/reservation/reservation-list.component.html new file mode 100644 index 0000000..3002446 --- /dev/null +++ b/src/app/features/reservation/reservation-list.component.html @@ -0,0 +1,147 @@ +
+
+

{{ isHost ? 'Reservation Requests' : 'My Reservations' }}

+ @if (!loading() && !error() && reservations().length > 0) { + + @for (s of statusFilters; track s) { + {{ s }} + } + + } +
+ + @if (loading()) { +
+ +

Loading reservations...

+
+ } + + @if (error()) { +
+ error_outline +

{{ error() }}

+ +
+ } + + @if (!loading() && !error() && reservations().length === 0) { +
+ calendar_month +

No reservations yet

+

{{ isGuest ? 'You have no reservation requests.' : 'No guests have reserved your accommodations yet.' }}

+
+ } + + @if (!loading() && !error() && reservations().length > 0 && filteredReservations().length === 0) { +
+ filter_list_off +

No matches

+

No reservations with status "{{ selectedStatus() }}".

+
+ } + + @if (!loading() && !error() && filteredReservations().length > 0) { +
+ @for (reservation of filteredReservations(); track reservation.id) { + +
+ + {{ reservation.status }} + + {{ reservation.totalPrice | currency }} +
+ + +
+ home + +
+
+ people + {{ reservation.guestCount }} {{ reservation.guestCount === 1 ? 'guest' : 'guests' }} +
+
+ calendar_today + {{ reservation.startDate | date:'mediumDate' }} – {{ reservation.endDate | date:'mediumDate' }} +
+ @if (isHost) { +
+ person + {{ guestNames().get(reservation.guestId) || ('Guest: ' + reservation.guestId.substring(0, 8) + '…') }} +
+ } +
+ + @if (isGuest) { + + Requested {{ reservation.createdAt | date:'mediumDate' }} + + @if (confirmingId() === reservation.id) { +
+ Are you sure? + + +
+ } @else { +
+ @if (reservation.status === ReservationStatus.PENDING) { + + } + @if (reservation.status === ReservationStatus.APPROVED && canCancel(reservation)) { + + } + @if (reservation.status === ReservationStatus.APPROVED && !canCancel(reservation)) { + + + + } +
+ } +
+ } +
+ } +
+ } +
diff --git a/src/app/features/reservation/reservation-list.component.scss b/src/app/features/reservation/reservation-list.component.scss new file mode 100644 index 0000000..8938791 --- /dev/null +++ b/src/app/features/reservation/reservation-list.component.scss @@ -0,0 +1,242 @@ +.reservation-list-container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +.list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + + h1 { + font-size: 2rem; + font-weight: 700; + color: var(--mat-sys-on-surface, #1a1a1a); + margin: 0; + } +} + +mat-button-toggle-group { + ::ng-deep .mat-button-toggle-checked { + background-color: var(--mat-sys-surface-container); + } +} + +.reservation-list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; +} + +.reservation-card { + border-left: 4px solid transparent; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transition: transform 200ms ease, box-shadow 200ms ease; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + } + + &.status-pending { + border-left-color: #F59E0B; + } + + &.status-approved { + border-left-color: var(--mat-sys-primary, #0D5C63); + } + + &.status-rejected { + border-left-color: var(--mat-sys-error, #dc3545); + opacity: 0.7; + } + + &.status-cancelled { + border-left-color: var(--mat-sys-outline-variant, #ccc); + opacity: 0.65; + } +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1rem 0.5rem; +} + +.status-chip { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + + &.status-chip-pending { + background-color: #FEF3C7; + color: #92400E; + } + + &.status-chip-approved { + background-color: color-mix(in srgb, var(--mat-sys-primary, #0D5C63) 15%, transparent); + color: var(--mat-sys-primary, #0D5C63); + } + + &.status-chip-rejected { + background-color: color-mix(in srgb, var(--mat-sys-error, #dc3545) 12%, transparent); + color: var(--mat-sys-error, #dc3545); + } + + &.status-chip-cancelled { + background-color: color-mix(in srgb, var(--mat-sys-outline-variant, #ccc) 30%, transparent); + color: var(--mat-sys-on-surface-variant, #666); + } +} + +.price { + font-size: 1.25rem; + font-weight: 700; + color: var(--mat-sys-primary, #0D5C63); +} + +.card-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem 1rem; + padding: 0.5rem 1rem 1rem; +} + +.info-item { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--mat-sys-on-surface-variant, #666); + font-size: 0.9rem; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: var(--mat-sys-outline, #aaa); + flex-shrink: 0; + } + + &--right { + justify-content: flex-end; + } +} + +.accommodation-link { + color: var(--mat-sys-primary, #0D5C63); + font-size: 0.9rem; + padding: 1rem 0.5rem; + min-width: unset; + line-height: 1.4; +} + +.guest-id { + font-family: monospace; + font-size: 0.8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; +} + +.card-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem 1rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.created-at { + font-size: 0.8rem; + color: var(--mat-sys-on-surface-variant, #999); +} + +.action-buttons { + display: flex; + gap: 0.5rem; +} + +.inline-confirm { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.confirm-text { + font-size: 0.875rem; + color: var(--mat-sys-on-surface-variant, #666); +} + +.loading-container, +.error-container, +.empty-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + color: var(--mat-sys-on-surface-variant, #666); + + mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + margin-bottom: 1rem; + color: var(--mat-sys-outline, #ccc); + } + + p { + margin: 0.5rem 0 1rem; + } + + h2 { + margin: 0; + color: var(--mat-sys-on-surface, #1a1a1a); + } +} + +.error-container { + mat-icon { + color: var(--mat-sys-error, #dc3545); + } +} + +@media (max-width: 768px) { + .reservation-list-container { + padding: 1rem; + } + + .list-header { + h1 { + font-size: 1.5rem; + } + } + + .reservation-list { + grid-template-columns: 1fr; + } + + .card-body { + grid-template-columns: 1fr; + } + + .info-item--right { + justify-content: flex-start; + } + + .card-footer { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/app/features/reservation/reservation-list.component.ts b/src/app/features/reservation/reservation-list.component.ts index 6ab1cf5..bc291bd 100644 --- a/src/app/features/reservation/reservation-list.component.ts +++ b/src/app/features/reservation/reservation-list.component.ts @@ -1,20 +1,201 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { AuthService } from '@core/auth/services/auth.service'; +import { NotificationService } from '@core/services/notification.service'; +import { ReservationService } from '@core/services/reservation.service'; +import { AccommodationService } from '@core/services/accommodation.service'; +import { UserService } from '@core/services/user.service'; +import { ReservationResponse, ReservationStatus } from '@core/models/reservation.model'; +import { UserRole } from '@core/models/user.model'; @Component({ selector: 'app-reservation-list', standalone: true, - imports: [CommonModule], - template: ` -
-

My Reservations

-

Reservation list will be implemented here.

-
- `, - styles: [` - .container { - padding: 2rem; - } - `] + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatButtonModule, + MatButtonToggleModule, + MatCardModule, + MatIconModule, + MatProgressSpinnerModule, + MatTooltipModule + ], + templateUrl: './reservation-list.component.html', + styleUrl: './reservation-list.component.scss' }) -export class ReservationListComponent {} +export class ReservationListComponent implements OnInit { + private readonly reservationService = inject(ReservationService); + private readonly accommodationService = inject(AccommodationService); + private readonly userService = inject(UserService); + private readonly authService = inject(AuthService); + private readonly notificationService = inject(NotificationService); + private readonly router = inject(Router); + + reservations = signal([]); + loading = signal(true); + error = signal(null); + confirmingId = signal(null); + actionInProgress = signal(null); + accommodationNames = signal>(new Map()); + guestNames = signal>(new Map()); + + readonly ReservationStatus = ReservationStatus; + + selectedStatus = signal('ALL'); + + readonly statusFilters: Array = [ + 'ALL', ReservationStatus.PENDING, ReservationStatus.APPROVED, + ReservationStatus.REJECTED, ReservationStatus.CANCELLED + ]; + + filteredReservations = computed(() => { + const status = this.selectedStatus(); + const list = status === 'ALL' + ? this.reservations() + : this.reservations().filter(r => r.status === status); + return [...list].sort((a, b) => + new Date(b.startDate).getTime() - new Date(a.startDate).getTime() + ); + }); + + get isGuest(): boolean { + return this.authService.hasRole(UserRole.GUEST); + } + + get isHost(): boolean { + return this.authService.hasRole(UserRole.HOST); + } + + ngOnInit(): void { + this.loadReservations(); + } + + private loadReservations(): void { + this.loading.set(true); + this.error.set(null); + + const obs$ = this.isGuest + ? this.reservationService.getByGuest() + : this.reservationService.getByHost(); + + obs$.subscribe({ + next: (data) => { + this.reservations.set(data); + this.fetchNames(data); + }, + error: (err) => { + this.error.set('Failed to load reservations. Please try again.'); + this.loading.set(false); + console.error('Error loading reservations:', err); + } + }); + } + + private fetchNames(reservations: ReservationResponse[]): void { + const accommodationIds = [...new Set(reservations.map(r => r.accommodationId))]; + const accommodationRequests = Object.fromEntries( + accommodationIds.map(id => [id, this.accommodationService.getById(id).pipe(catchError(() => of(null)))]) + ); + + const guestIds = this.isHost + ? [...new Set(reservations.map(r => r.guestId))] + : []; + const guestRequests = Object.fromEntries( + guestIds.map(id => [id, this.userService.getById(id).pipe(catchError(() => of(null)))]) + ); + + const allRequests = { ...accommodationRequests, ...guestRequests }; + if (Object.keys(allRequests).length === 0) { + this.loading.set(false); + return; + } + + forkJoin(allRequests).subscribe({ + next: results => { + const accMap = new Map(); + for (const id of accommodationIds) { + const acc = results[id] as { name?: string } | null; + if (acc?.name) accMap.set(id, acc.name); + } + this.accommodationNames.set(accMap); + + const guestMap = new Map(); + for (const id of guestIds) { + const user = results[id] as { firstName?: string; lastName?: string } | null; + if (user?.firstName) guestMap.set(id, `${user.firstName} ${user.lastName}`); + } + this.guestNames.set(guestMap); + }, + complete: () => this.loading.set(false) + }); + } + + retry(): void { + this.loadReservations(); + } + + canCancel(reservation: ReservationResponse): boolean { + const startDate = new Date(reservation.startDate); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + return startDate > tomorrow; + } + + startConfirm(id: string): void { + this.confirmingId.set(id); + } + + cancelConfirm(): void { + this.confirmingId.set(null); + } + + deleteRequest(id: string): void { + this.actionInProgress.set(id); + this.confirmingId.set(null); + + this.reservationService.deleteRequest(id).subscribe({ + next: () => { + this.reservations.update(list => list.filter(r => r.id !== id)); + this.actionInProgress.set(null); + this.notificationService.showSuccess('Reservation request deleted.'); + }, + error: (err) => { + this.actionInProgress.set(null); + this.notificationService.showHttpError(err); + } + }); + } + + cancelReservation(id: string): void { + this.actionInProgress.set(id); + this.confirmingId.set(null); + + this.reservationService.cancel(id).subscribe({ + next: () => { + this.reservations.update(list => + list.map(r => r.id === id ? { ...r, status: ReservationStatus.CANCELLED } : r) + ); + this.actionInProgress.set(null); + this.notificationService.showSuccess('Reservation cancelled.'); + }, + error: (err) => { + this.actionInProgress.set(null); + this.notificationService.showHttpError(err); + } + }); + } + + navigateToAccommodation(accommodationId: string): void { + void this.router.navigate(['/accommodations', accommodationId]); + } +} diff --git a/src/app/layouts/topbar/topbar.component.ts b/src/app/layouts/topbar/topbar.component.ts index 21d0811..c695c26 100644 --- a/src/app/layouts/topbar/topbar.component.ts +++ b/src/app/layouts/topbar/topbar.component.ts @@ -33,7 +33,7 @@ export class TopbarComponent { protected readonly navItems: NavItem[] = [ { label: 'Accommodations', path: '/accommodations' }, - { label: 'My Reservations', path: '/reservations', roles: [UserRole.GUEST] }, + { label: 'Reservations', path: '/reservations', roles: [UserRole.GUEST, UserRole.HOST] }, { label: 'Ratings', path: '/ratings', roles: [UserRole.GUEST] } ]; diff --git a/src/app/shared/components/accommodation-card/accommodation-card.component.scss b/src/app/shared/components/accommodation-card/accommodation-card.component.scss index 5a403c3..80e4cb9 100644 --- a/src/app/shared/components/accommodation-card/accommodation-card.component.scss +++ b/src/app/shared/components/accommodation-card/accommodation-card.component.scss @@ -3,6 +3,7 @@ border-radius: 12px; overflow: hidden; background: var(--mat-sys-surface-container-lowest, #fff); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); transition: transform 200ms ease, box-shadow 200ms ease; height: 100%; display: flex; diff --git a/src/app/shared/components/accommodation-card/accommodation-card.component.ts b/src/app/shared/components/accommodation-card/accommodation-card.component.ts index da56966..8ba811e 100644 --- a/src/app/shared/components/accommodation-card/accommodation-card.component.ts +++ b/src/app/shared/components/accommodation-card/accommodation-card.component.ts @@ -72,7 +72,7 @@ export class AccommodationCardComponent implements OnInit { } navigateToDetail(): void { - this.router.navigate(['/accommodations', this.accommodation.id]); + void this.router.navigate(['/accommodations', this.accommodation.id]); } get isOwner(): boolean {