diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html
index 003ae49..3da6811 100644
--- a/frontend/src/app/app.html
+++ b/frontend/src/app/app.html
@@ -1,5 +1,8 @@
Travel Planner
Reisen planen, gemeinsam entscheiden.
+
diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts
index 40bd1a7..9c31733 100644
--- a/frontend/src/app/app.routes.ts
+++ b/frontend/src/app/app.routes.ts
@@ -1,4 +1,17 @@
import { Routes } from '@angular/router';
import { Callback } from './auth/callback/callback';
+import { authGuard } from './auth/auth.guard';
-export const routes: Routes = [{ path: 'auth/callback', component: Callback }];
+export const routes: Routes = [
+ { path: 'auth/callback', component: Callback },
+ {
+ path: 'trips',
+ canActivate: [authGuard],
+ loadComponent: () => import('./trips/trips-list/trips-list').then((m) => m.TripsList),
+ },
+ {
+ path: 'trips/:tripId',
+ canActivate: [authGuard],
+ loadComponent: () => import('./trips/trip-detail/trip-detail').then((m) => m.TripDetail),
+ },
+];
diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts
index 1821efb..d77bd18 100644
--- a/frontend/src/app/app.spec.ts
+++ b/frontend/src/app/app.spec.ts
@@ -1,10 +1,12 @@
import { TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
+ providers: [provideRouter([])],
}).compileComponents();
});
diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts
index 372b06c..597f18f 100644
--- a/frontend/src/app/app.ts
+++ b/frontend/src/app/app.ts
@@ -1,9 +1,9 @@
import { Component } from '@angular/core';
-import { RouterOutlet } from '@angular/router';
+import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
- imports: [RouterOutlet],
+ imports: [RouterOutlet, RouterLink],
templateUrl: './app.html',
styleUrl: './app.scss',
})
diff --git a/frontend/src/app/trips/trip-detail/trip-detail.html b/frontend/src/app/trips/trip-detail/trip-detail.html
new file mode 100644
index 0000000..8f761df
--- /dev/null
+++ b/frontend/src/app/trips/trip-detail/trip-detail.html
@@ -0,0 +1,16 @@
+
+ @if (trip(); as trip) {
+ {{ trip.name }}
+ }
+
+ @if (conflictError()) {
+ Die Reise wurde zwischenzeitlich von jemand anderem geƤndert. Bitte neu laden.
+ }
+
+ Mitglieder
+
+ @for (member of members(); track member.id) {
+ - {{ member.userId }} ({{ member.role }})
+ }
+
+
diff --git a/frontend/src/app/trips/trip-detail/trip-detail.spec.ts b/frontend/src/app/trips/trip-detail/trip-detail.spec.ts
new file mode 100644
index 0000000..17e2b63
--- /dev/null
+++ b/frontend/src/app/trips/trip-detail/trip-detail.spec.ts
@@ -0,0 +1,60 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
+import { describe, expect, it, vi } from 'vitest';
+import { TripDetail } from './trip-detail';
+import { TripsApiService } from '../trips-api.service';
+
+describe('TripDetail', () => {
+ it('renders the trip name and members for the routed tripId', async () => {
+ const tripsApi = {
+ getTrip: vi.fn().mockReturnValue(of({ id: 't1', name: 'Slovenia 2027', version: 1 })),
+ listMembers: vi.fn().mockReturnValue(of([{ id: 'm1', userId: 'u1', role: 'OWNER', status: 'ACTIVE' }])),
+ updateTrip: vi.fn(),
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [TripDetail],
+ providers: [
+ { provide: TripsApiService, useValue: tripsApi },
+ { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 't1' } } } },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TripDetail);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(tripsApi.getTrip).toHaveBeenCalledWith('t1');
+ expect(tripsApi.listMembers).toHaveBeenCalledWith('t1');
+ expect(fixture.nativeElement.textContent).toContain('Slovenia 2027');
+ });
+
+ it('shows a plain error message on a 409 optimistic-locking conflict', async () => {
+ const tripsApi = {
+ getTrip: vi.fn().mockReturnValue(of({ id: 't1', name: 'Slovenia 2027', version: 1 })),
+ listMembers: vi.fn().mockReturnValue(of([])),
+ updateTrip: vi.fn().mockReturnValue({
+ subscribe: (observer: { error: (err: unknown) => void }) => observer.error({ status: 409 }),
+ }),
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [TripDetail],
+ providers: [
+ { provide: TripsApiService, useValue: tripsApi },
+ { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 't1' } } } },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TripDetail);
+ const component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ component.rename('New name');
+
+ expect(component.conflictError()).toBe(true);
+ });
+});
diff --git a/frontend/src/app/trips/trip-detail/trip-detail.ts b/frontend/src/app/trips/trip-detail/trip-detail.ts
new file mode 100644
index 0000000..721957c
--- /dev/null
+++ b/frontend/src/app/trips/trip-detail/trip-detail.ts
@@ -0,0 +1,40 @@
+import { Component, inject, OnInit, signal } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Trip, TripMember, TripsApiService } from '../trips-api.service';
+
+@Component({
+ selector: 'app-trip-detail',
+ standalone: true,
+ imports: [],
+ templateUrl: './trip-detail.html',
+})
+export class TripDetail implements OnInit {
+ private readonly tripsApi = inject(TripsApiService);
+ private readonly route = inject(ActivatedRoute);
+ private tripId!: string;
+
+ readonly trip = signal(undefined);
+ readonly members = signal([]);
+ readonly conflictError = signal(false);
+
+ ngOnInit(): void {
+ this.tripId = this.route.snapshot.paramMap.get('tripId') as string;
+ this.tripsApi.getTrip(this.tripId).subscribe((trip) => this.trip.set(trip));
+ this.tripsApi.listMembers(this.tripId).subscribe((members) => this.members.set(members));
+ }
+
+ rename(name: string): void {
+ const current = this.trip();
+ if (!current) return;
+ this.conflictError.set(false);
+ this.tripsApi.updateTrip(this.tripId, { name, version: current.version }).subscribe({
+ next: (updated) => this.trip.set(updated),
+ error: (error: HttpErrorResponse) => {
+ if (error.status === 409) {
+ this.conflictError.set(true);
+ }
+ },
+ });
+ }
+}
diff --git a/frontend/src/app/trips/trips-api.service.spec.ts b/frontend/src/app/trips/trips-api.service.spec.ts
new file mode 100644
index 0000000..f5532ac
--- /dev/null
+++ b/frontend/src/app/trips/trips-api.service.spec.ts
@@ -0,0 +1,56 @@
+import { TestBed } from '@angular/core/testing';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { describe, expect, it, afterEach } from 'vitest';
+import { TripsApiService } from './trips-api.service';
+
+describe('TripsApiService', () => {
+ let service: TripsApiService;
+ let httpMock: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [provideHttpClient(), provideHttpClientTesting()],
+ });
+ service = TestBed.inject(TripsApiService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it('lists trips from /api/v1/trips', () => {
+ let result: unknown;
+ service.listTrips().subscribe((trips) => (result = trips));
+
+ const req = httpMock.expectOne('/api/v1/trips');
+ expect(req.request.method).toBe('GET');
+ req.flush([{ id: 't1', name: 'Slovenia 2027' }]);
+
+ expect(result).toEqual([{ id: 't1', name: 'Slovenia 2027' }]);
+ });
+
+ it('creates a trip via POST /api/v1/trips', () => {
+ service.createTrip({ name: 'Slovenia 2027' }).subscribe();
+
+ const req = httpMock.expectOne('/api/v1/trips');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ name: 'Slovenia 2027' });
+ req.flush({ id: 't1', name: 'Slovenia 2027' });
+ });
+
+ it('gets a single trip via GET /api/v1/trips/:id', () => {
+ service.getTrip('t1').subscribe();
+ const req = httpMock.expectOne('/api/v1/trips/t1');
+ expect(req.request.method).toBe('GET');
+ req.flush({ id: 't1' });
+ });
+
+ it('lists members via GET /api/v1/trips/:id/members', () => {
+ service.listMembers('t1').subscribe();
+ const req = httpMock.expectOne('/api/v1/trips/t1/members');
+ expect(req.request.method).toBe('GET');
+ req.flush([]);
+ });
+});
diff --git a/frontend/src/app/trips/trips-api.service.ts b/frontend/src/app/trips/trips-api.service.ts
new file mode 100644
index 0000000..247da59
--- /dev/null
+++ b/frontend/src/app/trips/trips-api.service.ts
@@ -0,0 +1,69 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+import { environment } from '../../environments/environment';
+
+export type TripStatus = 'DRAFT' | 'PLANNING' | 'BOOKING' | 'UPCOMING' | 'ACTIVE' | 'COMPLETED' | 'ARCHIVED';
+
+export interface Trip {
+ id: string;
+ name: string;
+ description: string | null;
+ ownerId: string;
+ startDate: string | null;
+ endDate: string | null;
+ status: TripStatus;
+ planningStage: string | null;
+ currency: string;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CreateTripDto {
+ name: string;
+ description?: string;
+ startDate?: string;
+ endDate?: string;
+ currency?: string;
+}
+
+export interface UpdateTripDto {
+ name?: string;
+ description?: string | null;
+ version: number;
+}
+
+export interface TripMember {
+ id: string;
+ tripId: string;
+ userId: string;
+ role: 'OWNER' | 'MEMBER';
+ status: 'INVITED' | 'ACTIVE' | 'DECLINED';
+}
+
+@Injectable({ providedIn: 'root' })
+export class TripsApiService {
+ private readonly http = inject(HttpClient);
+ private readonly baseUrl = `${environment.apiBaseUrl}/trips`;
+
+ listTrips(): Observable {
+ return this.http.get(this.baseUrl);
+ }
+
+ createTrip(dto: CreateTripDto): Observable {
+ return this.http.post(this.baseUrl, dto);
+ }
+
+ getTrip(tripId: string): Observable {
+ return this.http.get(`${this.baseUrl}/${tripId}`);
+ }
+
+ updateTrip(tripId: string, dto: UpdateTripDto): Observable {
+ return this.http.patch(`${this.baseUrl}/${tripId}`, dto);
+ }
+
+ listMembers(tripId: string): Observable {
+ return this.http.get(`${this.baseUrl}/${tripId}/members`);
+ }
+}
diff --git a/frontend/src/app/trips/trips-list/trips-list.html b/frontend/src/app/trips/trips-list/trips-list.html
new file mode 100644
index 0000000..c512875
--- /dev/null
+++ b/frontend/src/app/trips/trips-list/trips-list.html
@@ -0,0 +1,14 @@
+
diff --git a/frontend/src/app/trips/trips-list/trips-list.spec.ts b/frontend/src/app/trips/trips-list/trips-list.spec.ts
new file mode 100644
index 0000000..efe7169
--- /dev/null
+++ b/frontend/src/app/trips/trips-list/trips-list.spec.ts
@@ -0,0 +1,49 @@
+import { TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import { of } from 'rxjs';
+import { describe, expect, it, vi } from 'vitest';
+import { TripsList } from './trips-list';
+import { TripsApiService } from '../trips-api.service';
+
+describe('TripsList', () => {
+ it('renders trip names returned by TripsApiService', async () => {
+ const tripsApi = {
+ listTrips: vi.fn().mockReturnValue(of([{ id: 't1', name: 'Slovenia 2027' }])),
+ createTrip: vi.fn(),
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [TripsList],
+ providers: [{ provide: TripsApiService, useValue: tripsApi }, provideRouter([])],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TripsList);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toContain('Slovenia 2027');
+ });
+
+ it('calls createTrip when the create form is submitted', async () => {
+ const tripsApi = {
+ listTrips: vi.fn().mockReturnValue(of([])),
+ createTrip: vi.fn().mockReturnValue(of({ id: 't2', name: 'New Trip' })),
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [TripsList],
+ providers: [{ provide: TripsApiService, useValue: tripsApi }, provideRouter([])],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TripsList);
+ const component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ component.newTripName.set('New Trip');
+ component.createTrip();
+
+ expect(tripsApi.createTrip).toHaveBeenCalledWith({ name: 'New Trip' });
+ });
+});
diff --git a/frontend/src/app/trips/trips-list/trips-list.ts b/frontend/src/app/trips/trips-list/trips-list.ts
new file mode 100644
index 0000000..88fe2a2
--- /dev/null
+++ b/frontend/src/app/trips/trips-list/trips-list.ts
@@ -0,0 +1,30 @@
+import { Component, inject, OnInit, signal } from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { FormsModule } from '@angular/forms';
+import { Trip, TripsApiService } from '../trips-api.service';
+
+@Component({
+ selector: 'app-trips-list',
+ standalone: true,
+ imports: [RouterLink, FormsModule],
+ templateUrl: './trips-list.html',
+})
+export class TripsList implements OnInit {
+ private readonly tripsApi = inject(TripsApiService);
+
+ readonly trips = signal([]);
+ readonly newTripName = signal('');
+
+ ngOnInit(): void {
+ this.tripsApi.listTrips().subscribe((trips) => this.trips.set(trips));
+ }
+
+ createTrip(): void {
+ const name = this.newTripName().trim();
+ if (!name) return;
+ this.tripsApi.createTrip({ name }).subscribe((trip) => {
+ this.trips.update((trips) => [...trips, trip]);
+ this.newTripName.set('');
+ });
+ }
+}