From 7a583d81a6b3ef7b81e5f40d8745b6ea5fa257e5 Mon Sep 17 00:00:00 2001 From: Jim Martens Date: Mon, 4 Dec 2023 18:52:09 +0100 Subject: [PATCH] feat: Add route editing functionality (#20) fix(formations): Don't overwrite existing formations in local storage --- angular.json | 5 + package-lock.json | 142 ++++++++++++++-- package.json | 1 + src/app/app.routes.ts | 12 +- src/app/dashboard/dashboard.component.html | 26 ++- src/app/dashboard/dashboard.component.ts | 48 ++++-- src/app/formations/formations.component.ts | 4 +- src/app/formations/model/formation.ts | 4 +- .../formations/service/formations.service.ts | 1 + .../update-formation.component.ts | 18 +- src/app/routes/common/depot-component.ts | 116 +++++++++++++ src/app/routes/common/portal-component.ts | 85 ++++++++++ src/app/routes/common/route-component.ts | 160 ++++++++++++++++++ .../create-depot/create-depot.component.html | 139 +++++++++++++++ .../create-depot/create-depot.component.scss | 3 + .../create-depot.component.spec.ts | 23 +++ .../create-depot/create-depot.component.ts | 100 +++++++++++ .../create-portal.component.html | 111 ++++++++++++ .../create-portal.component.scss | 3 + .../create-portal.component.spec.ts | 23 +++ .../create-portal/create-portal.component.ts | 84 +++++++++ .../create-route/create-route.component.html | 148 ++++++++++++++++ .../create-route/create-route.component.scss | 3 + .../create-route.component.spec.ts | 23 +++ .../create-route/create-route.component.ts | 113 +++++++++++++ src/app/routes/model/depot.ts | 25 +++ src/app/routes/model/portal.ts | 17 ++ src/app/routes/model/route.ts | 23 ++- src/app/routes/model/station.ts | 10 +- src/app/routes/model/travel-duration.ts | 6 + src/app/routes/routes.component.html | 34 ++-- src/app/routes/routes.component.ts | 63 +++++-- src/app/routes/service/route.service.spec.ts | 16 ++ src/app/routes/service/route.service.ts | 75 ++++++++ .../service/routes-store.service.spec.ts | 16 ++ .../routes/service/routes-store.service.ts | 64 +++++++ .../routes/service/station.service.spec.ts | 16 ++ src/app/routes/service/station.service.ts | 38 +++++ src/app/routes/store/index.ts | 40 +++++ src/app/routes/store/routes.actions.ts | 71 ++++++++ src/app/routes/store/routes.effects.ts | 29 ++++ src/app/routes/store/routes.reducer.ts | 82 +++++++++ .../update-depot/update-depot.component.html | 133 +++++++++++++++ .../update-depot/update-depot.component.scss | 3 + .../update-depot.component.spec.ts | 23 +++ .../update-depot/update-depot.component.ts | 84 +++++++++ .../update-portal.component.html | 107 ++++++++++++ .../update-portal.component.scss | 3 + .../update-portal.component.spec.ts | 23 +++ .../update-portal/update-portal.component.ts | 108 ++++++++++++ .../update-route/update-route.component.html | 142 ++++++++++++++++ .../update-route/update-route.component.scss | 3 + .../update-route.component.spec.ts | 23 +++ .../update-route/update-route.component.ts | 123 ++++++++++++++ src/app/typeahead/item.ts | 4 + src/app/typeahead/typeahead.component.html | 18 ++ src/app/typeahead/typeahead.component.scss | 0 src/app/typeahead/typeahead.component.spec.ts | 23 +++ src/app/typeahead/typeahead.component.ts | 64 +++++++ 59 files changed, 2840 insertions(+), 64 deletions(-) create mode 100644 src/app/routes/common/depot-component.ts create mode 100644 src/app/routes/common/portal-component.ts create mode 100644 src/app/routes/common/route-component.ts create mode 100644 src/app/routes/create-depot/create-depot.component.html create mode 100644 src/app/routes/create-depot/create-depot.component.scss create mode 100644 src/app/routes/create-depot/create-depot.component.spec.ts create mode 100644 src/app/routes/create-depot/create-depot.component.ts create mode 100644 src/app/routes/create-portal/create-portal.component.html create mode 100644 src/app/routes/create-portal/create-portal.component.scss create mode 100644 src/app/routes/create-portal/create-portal.component.spec.ts create mode 100644 src/app/routes/create-portal/create-portal.component.ts create mode 100644 src/app/routes/create-route/create-route.component.html create mode 100644 src/app/routes/create-route/create-route.component.scss create mode 100644 src/app/routes/create-route/create-route.component.spec.ts create mode 100644 src/app/routes/create-route/create-route.component.ts create mode 100644 src/app/routes/model/depot.ts create mode 100644 src/app/routes/model/portal.ts create mode 100644 src/app/routes/model/travel-duration.ts create mode 100644 src/app/routes/service/route.service.spec.ts create mode 100644 src/app/routes/service/route.service.ts create mode 100644 src/app/routes/service/routes-store.service.spec.ts create mode 100644 src/app/routes/service/routes-store.service.ts create mode 100644 src/app/routes/service/station.service.spec.ts create mode 100644 src/app/routes/service/station.service.ts create mode 100644 src/app/routes/store/index.ts create mode 100644 src/app/routes/store/routes.actions.ts create mode 100644 src/app/routes/store/routes.effects.ts create mode 100644 src/app/routes/store/routes.reducer.ts create mode 100644 src/app/routes/update-depot/update-depot.component.html create mode 100644 src/app/routes/update-depot/update-depot.component.scss create mode 100644 src/app/routes/update-depot/update-depot.component.spec.ts create mode 100644 src/app/routes/update-depot/update-depot.component.ts create mode 100644 src/app/routes/update-portal/update-portal.component.html create mode 100644 src/app/routes/update-portal/update-portal.component.scss create mode 100644 src/app/routes/update-portal/update-portal.component.spec.ts create mode 100644 src/app/routes/update-portal/update-portal.component.ts create mode 100644 src/app/routes/update-route/update-route.component.html create mode 100644 src/app/routes/update-route/update-route.component.scss create mode 100644 src/app/routes/update-route/update-route.component.spec.ts create mode 100644 src/app/routes/update-route/update-route.component.ts create mode 100644 src/app/typeahead/item.ts create mode 100644 src/app/typeahead/typeahead.component.html create mode 100644 src/app/typeahead/typeahead.component.scss create mode 100644 src/app/typeahead/typeahead.component.spec.ts create mode 100644 src/app/typeahead/typeahead.component.ts diff --git a/angular.json b/angular.json index 2b4bdc5..187043b 100644 --- a/angular.json +++ b/angular.json @@ -197,10 +197,15 @@ }, "cli": { "schematicCollections": [ + "@schematics/angular", "@ionic/angular-toolkit" ] }, "schematics": { + "@schematics/angular:component": { + "standalone": true, + "style": "scss" + }, "@ionic/angular-toolkit:component": { "styleext": "scss" }, diff --git a/package-lock.json b/package-lock.json index 02b7be1..3f0f0ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "@angular/localize": "^17.0.2", "@capacitor/cli": "5.5.1", "@ionic/angular-toolkit": "^9.0.0", + "@schematics/angular": "^17.0.5", "@types/jasmine": "~4.3.0", "@types/node": "^12.11.1", "@typescript-eslint/eslint-plugin": "^6.10.0", @@ -1317,6 +1318,22 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/@schematics/angular": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.0.tgz", + "integrity": "sha512-9jKU5x/WzaBsfSkUowK1X74FqtMXa6+A60XgW4ACO8i6fwKfPeS+tIrAieeYOX80/njBh7I5CvcpHmWA2SbcXQ==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "17.0.0", + "@angular-devkit/schematics": "17.0.0", + "jsonc-parser": "3.2.0" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, "node_modules/@angular/common": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.0.2.tgz", @@ -4562,13 +4579,13 @@ "dev": true }, "node_modules/@schematics/angular": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.0.tgz", - "integrity": "sha512-9jKU5x/WzaBsfSkUowK1X74FqtMXa6+A60XgW4ACO8i6fwKfPeS+tIrAieeYOX80/njBh7I5CvcpHmWA2SbcXQ==", + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.5.tgz", + "integrity": "sha512-sOc1UG4NiV+7cGwrbWPnyW71O+NgsKaFb2agSrVduRL7o4neMDeqF04ik4Kv1jKA7sZOQfPV+3cn6XI49Mumrw==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.0.0", - "@angular-devkit/schematics": "17.0.0", + "@angular-devkit/core": "17.0.5", + "@angular-devkit/schematics": "17.0.5", "jsonc-parser": "3.2.0" }, "engines": { @@ -4577,6 +4594,63 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@schematics/angular/node_modules/@angular-devkit/core": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.0.5.tgz", + "integrity": "sha512-e1evgRabAfOZBnmFCe8E0oufcu+FzBe5hBzS94Dm42GlxdX965/M4yVKQxIMpjivQTmjl+AWb6cF1ltBdSGZeQ==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "picomatch": "3.0.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@schematics/angular/node_modules/@angular-devkit/schematics": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.0.5.tgz", + "integrity": "sha512-KYPku0qTb8B+TtRbFqXGYpJOPg1k6d5bNHV6n8jTc35mlEUUghOd7HkovdfkQ3cgGNQM56a74D1CvSeruZEGsA==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "17.0.5", + "jsonc-parser": "3.2.0", + "magic-string": "0.30.5", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@schematics/angular/node_modules/picomatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@sigstore/bundle": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz", @@ -18261,6 +18335,19 @@ "semver": "7.5.4", "symbol-observable": "4.0.0", "yargs": "17.7.2" + }, + "dependencies": { + "@schematics/angular": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.0.tgz", + "integrity": "sha512-9jKU5x/WzaBsfSkUowK1X74FqtMXa6+A60XgW4ACO8i6fwKfPeS+tIrAieeYOX80/njBh7I5CvcpHmWA2SbcXQ==", + "dev": true, + "requires": { + "@angular-devkit/core": "17.0.0", + "@angular-devkit/schematics": "17.0.0", + "jsonc-parser": "3.2.0" + } + } } }, "@angular/common": { @@ -20503,14 +20590,49 @@ "dev": true }, "@schematics/angular": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.0.tgz", - "integrity": "sha512-9jKU5x/WzaBsfSkUowK1X74FqtMXa6+A60XgW4ACO8i6fwKfPeS+tIrAieeYOX80/njBh7I5CvcpHmWA2SbcXQ==", + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.5.tgz", + "integrity": "sha512-sOc1UG4NiV+7cGwrbWPnyW71O+NgsKaFb2agSrVduRL7o4neMDeqF04ik4Kv1jKA7sZOQfPV+3cn6XI49Mumrw==", "dev": true, "requires": { - "@angular-devkit/core": "17.0.0", - "@angular-devkit/schematics": "17.0.0", + "@angular-devkit/core": "17.0.5", + "@angular-devkit/schematics": "17.0.5", "jsonc-parser": "3.2.0" + }, + "dependencies": { + "@angular-devkit/core": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.0.5.tgz", + "integrity": "sha512-e1evgRabAfOZBnmFCe8E0oufcu+FzBe5hBzS94Dm42GlxdX965/M4yVKQxIMpjivQTmjl+AWb6cF1ltBdSGZeQ==", + "dev": true, + "requires": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.0", + "picomatch": "3.0.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + } + }, + "@angular-devkit/schematics": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.0.5.tgz", + "integrity": "sha512-KYPku0qTb8B+TtRbFqXGYpJOPg1k6d5bNHV6n8jTc35mlEUUghOd7HkovdfkQ3cgGNQM56a74D1CvSeruZEGsA==", + "dev": true, + "requires": { + "@angular-devkit/core": "17.0.5", + "jsonc-parser": "3.2.0", + "magic-string": "0.30.5", + "ora": "5.4.1", + "rxjs": "7.8.1" + } + }, + "picomatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "dev": true + } } }, "@sigstore/bundle": { diff --git a/package.json b/package.json index 9c1fa67..a04dd0d 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@angular/localize": "^17.0.2", "@capacitor/cli": "5.5.1", "@ionic/angular-toolkit": "^9.0.0", + "@schematics/angular": "^17.0.5", "@types/jasmine": "~4.3.0", "@types/node": "^12.11.1", "@typescript-eslint/eslint-plugin": "^6.10.0", diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 106d8da..6005991 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -6,6 +6,8 @@ import {provideEffects} from "@ngrx/effects"; import {formationsReducer} from "./formations/store/formations.reducer"; import {subscriptionReducer} from "./subscription/store/subscription.reducer"; import {subscriptionEffects, subscriptionFeature} from "./subscription/store"; +import {routesReducer} from "./routes/store/routes.reducer"; +import {featureStateName as routesFeature, routesEffects} from "./routes/store"; export const ROOT_ROUTES: Routes = [ { @@ -46,7 +48,12 @@ export const ROOT_ROUTES: Routes = [ { path: 'routes', loadComponent: () => import("./routes/routes.component").then(mod => mod.RoutesComponent), - canActivate: [AppAuthGuard] + canActivate: [AppAuthGuard], + providers: [ + provideState(routesFeature, routesReducer), + provideState(formationsFeature, formationsReducer), + provideEffects(routesEffects, formationsEffects) + ] }, { path: 'timetables', @@ -68,7 +75,8 @@ export const ROOT_ROUTES: Routes = [ canActivate: [AppAuthGuard], providers: [ provideState(formationsFeature, formationsReducer), - provideEffects(formationsEffects) + provideState(routesFeature, routesReducer), + provideEffects(formationsEffects, routesEffects) ], }, { diff --git a/src/app/dashboard/dashboard.component.html b/src/app/dashboard/dashboard.component.html index a909d0c..c293e33 100644 --- a/src/app/dashboard/dashboard.component.html +++ b/src/app/dashboard/dashboard.component.html @@ -9,7 +9,12 @@ - Routes + + Routes + @if (!(hasPersonalPlan$ | async)) { + ({{ (routes$ | async)?.length }} / 1) + } + Name @@ -18,7 +23,7 @@ Last Station # Stations - + {{ route.name }} {{ route.country.name }} @@ -27,7 +32,11 @@ {{ route.numberOfStations }} - + + + Update + + Delete @@ -99,14 +108,19 @@ - - - + @if ((routes$ | async)?.length == 0 || (hasPersonalPlan$ | async)) { + + + + } + + diff --git a/src/app/dashboard/dashboard.component.ts b/src/app/dashboard/dashboard.component.ts index a49f8c8..39c7f9c 100644 --- a/src/app/dashboard/dashboard.component.ts +++ b/src/app/dashboard/dashboard.component.ts @@ -40,7 +40,7 @@ import { trashOutline, trashSharp } from "ionicons/icons"; -import {Route} from "../routes/model/route"; +import {DEFAULT_ROUTE, Route} from "../routes/model/route"; import {Timetable} from "../timetables/model/timetable"; import {FormationsStoreService} from "../formations/service/formations-store.service"; import {DEFAULT_FORMATION, Formation} from "../formations/model/formation"; @@ -53,6 +53,12 @@ import {ActivatedRoute, EventType, NavigationEnd, Router} from "@angular/router" import {addMessageAction} from "../messages/store/messages.actions"; import {Message} from "../messages/model/message"; import {MessagesState} from "../messages/store/messages.reducer"; +import {CreateRouteComponent} from "../routes/create-route/create-route.component"; +import {UpdateRouteComponent} from "../routes/update-route/update-route.component"; +import {RoutesStoreService} from "../routes/service/routes-store.service"; +import {RoutesState} from "../routes/store/routes.reducer"; +import {deleteRouteAction} from "../routes/store/routes.actions"; +import {AuthService} from "../auth/service/auth.service"; @Component({ selector: 'app-dashboard', @@ -85,19 +91,12 @@ import {MessagesState} from "../messages/store/messages.reducer"; IonFabList, IonFooter, CreateFormationComponent, - UpdateFormationComponent + UpdateFormationComponent, + CreateRouteComponent, + UpdateRouteComponent ] }) export class DashboardComponent implements OnDestroy { - routes: Route[] = [ - { - name: 'Köln-Aachen', - country: {name: $localize`Germany`, code: 'de'}, - firstStation: {name: 'Köln Hbf'}, - lastStation: {name: 'Aachen Hbf'}, - numberOfStations: 30 - } - ]; timetables: Timetable[] = []; isCreateFormationModalOpen = false; @@ -106,6 +105,19 @@ export class DashboardComponent implements OnDestroy { private readonly formationsStoreService: FormationsStoreService = inject(FormationsStoreService); formations$ = this.formationsStoreService.getFormations$(); + isCreateRouteModalOpen = false; + isUpdateRouteModalOpen = false; + updatedRoute: Route = DEFAULT_ROUTE; + private readonly routesStoreService: RoutesStoreService = inject(RoutesStoreService); + routes$ = this.routesStoreService.getRoutes$(); + + private readonly authService: AuthService = inject(AuthService); + readonly user$ = this.authService.getUser$(); + readonly hasPersonalPlan$ = this.user$.pipe( + map(user => user.roles), + filter(roles => roles.includes('PERSONAL_PLAN')) + ); + private messages: Record = { success: { text: $localize`You have successfully subscribed. The subscription can be managed from the account settings.`, @@ -117,6 +129,7 @@ export class DashboardComponent implements OnDestroy { private subscription: Subscription; constructor(private readonly formationsStore: Store, + private readonly routesStore: Store, private readonly messagesStore: Store, private readonly activatedRoute: ActivatedRoute, private readonly router: Router) { @@ -160,6 +173,19 @@ export class DashboardComponent implements OnDestroy { this.formationsStore.dispatch(deleteFormationAction({payload: formation})); } + addRoute() { + this.isCreateRouteModalOpen = true; + } + + updateRoute(route: Route) { + this.isUpdateRouteModalOpen = true; + this.updatedRoute = route; + } + + deleteRoute(route: Route) { + this.routesStore.dispatch(deleteRouteAction({payload: route})); + } + private triggerFeedbackMessage() { const state = this.activatedRoute.snapshot.queryParamMap.get('state') || ''; if (state in this.messages) { diff --git a/src/app/formations/formations.component.ts b/src/app/formations/formations.component.ts index 6af5c45..240c800 100644 --- a/src/app/formations/formations.component.ts +++ b/src/app/formations/formations.component.ts @@ -61,7 +61,7 @@ import {FormationsState} from "./store/formations.reducer"; export class FormationsComponent { isCreateModalOpen = false; isUpdateModalOpen = false; - updatedFormation: Formation = DEFAULT_FORMATION; + updatedFormation: Formation = {...DEFAULT_FORMATION}; private readonly storeService: FormationsStoreService = inject(FormationsStoreService); formations$ = this.storeService.getFormations$(); @@ -82,8 +82,8 @@ export class FormationsComponent { } updateFormation(formation: Formation) { - this.isUpdateModalOpen = true; this.updatedFormation = formation; + this.isUpdateModalOpen = true; } deleteFormation(formation: Formation) { diff --git a/src/app/formations/model/formation.ts b/src/app/formations/model/formation.ts index f42aa2b..b546999 100644 --- a/src/app/formations/model/formation.ts +++ b/src/app/formations/model/formation.ts @@ -1,4 +1,6 @@ -export interface Formation { +import {Item} from "../../typeahead/item"; + +export interface Formation extends Item { id: string; name: string; trainSimWorldFormation?: Formation; diff --git a/src/app/formations/service/formations.service.ts b/src/app/formations/service/formations.service.ts index 723293d..962588d 100644 --- a/src/app/formations/service/formations.service.ts +++ b/src/app/formations/service/formations.service.ts @@ -25,6 +25,7 @@ export class FormationsService { fetchFormations(): Observable { if (environment.mockNetwork) { this.formations = JSON.parse(localStorage.getItem("formations") || '[]'); + this.formations.forEach(formation => this.knownFormations.set(formation.id, formation)); return of(this.formations); } diff --git a/src/app/formations/update-formation/update-formation.component.ts b/src/app/formations/update-formation/update-formation.component.ts index 17189ba..b2b671f 100644 --- a/src/app/formations/update-formation/update-formation.component.ts +++ b/src/app/formations/update-formation/update-formation.component.ts @@ -48,10 +48,10 @@ import {FormationsState} from "../store/formations.reducer"; export class UpdateFormationComponent { @ViewChild(IonModal) modal: IonModal | undefined; - @Input({required: true}) isOpen: boolean = false; @Output() dismissed: EventEmitter = new EventEmitter(); formation: Formation = {...DEFAULT_FORMATION}; + formationOnOpen: Formation = {...this.formation}; private readonly store: Store = inject(Store); private readonly storeService: FormationsStoreService = inject(FormationsStoreService); @@ -60,13 +60,26 @@ export class UpdateFormationComponent { constructor() { } + private _isOpen = false; + @Input({required: true}) set isOpen(newValue: boolean) { + if (!this._isOpen && newValue) { + this.formationOnOpen = {...this.formation}; + } + this._isOpen = newValue; + } + + get isOpen() { + return this._isOpen; + } + @Input({required: true}) set updatedFormation(newValue: Formation) { this.formation = {...newValue}; + this.formationOnOpen = {...newValue}; } cancel() { this.dismissed.emit(true); - this.formation = {...DEFAULT_FORMATION}; + this.formation = {...this.formationOnOpen}; } confirm() { @@ -74,7 +87,6 @@ export class UpdateFormationComponent { payload: {...this.formation} })) this.dismissed.emit(true); - this.formation = {...DEFAULT_FORMATION}; } compareWith(formation1: Formation, formation2: Formation) { diff --git a/src/app/routes/common/depot-component.ts b/src/app/routes/common/depot-component.ts new file mode 100644 index 0000000..9df0cc4 --- /dev/null +++ b/src/app/routes/common/depot-component.ts @@ -0,0 +1,116 @@ +import {Station} from "../model/station"; +import {InputCustomEvent, SelectCustomEvent} from "@ionic/angular"; +import {Formation} from "../../formations/model/formation"; +import {TravelDuration} from "../model/travel-duration"; +import {DEFAULT_DEPOT, Depot, Track} from "../model/depot"; +import {FormationsStoreService} from "../../formations/service/formations-store.service"; +import {inject} from "@angular/core"; +import {map, Observable} from "rxjs"; + +export class DepotComponent { + depot: Depot = {...DEFAULT_DEPOT}; + usedFormations: Formation[] = []; + isFormationPopoverOpen = false; + clickEvent: MouseEvent = new MouseEvent('mouseup'); + travelDurationIndex?: number; + unusedFormations$!: Observable; + private readonly storeService: FormationsStoreService = inject(FormationsStoreService); + readonly formations$ = this.storeService.getFormations$(); + + compareWithStation(station1: Station, station2: Station) { + return station1 && station2 ? station1.id === station2.id : station1 === station2; + } + + compareWithFormation(formation1: Formation, formation2: Formation) { + return formation1 && formation2 ? formation1.id == formation2.id : formation1 === formation2; + } + + addTrack() { + const newTracks = [...this.depot.tracks]; + const newId = Math.max(...newTracks.map(track => track.id)) + 1; + newTracks.push({id: newId, name: '', capacity: 0}); + this.depot.tracks = newTracks; + } + + changeName(index: number, event: InputCustomEvent) { + if (event.detail.value != null) { + const newTracks = [...this.depot.tracks]; + newTracks[index] = {...newTracks[index], name: event.detail.value}; + this.depot.tracks = newTracks; + } + } + + changeCapacity(index: number, event: InputCustomEvent) { + if (event.detail.value != null) { + const newTracks = [...this.depot.tracks]; + newTracks[index] = {...newTracks[index], capacity: +event.detail.value}; + this.depot.tracks = newTracks; + } + } + + deleteTrack(deletedTrack: Track) { + this.depot.tracks = this.depot.tracks.filter( + track => track.id !== deletedTrack.id + ); + } + + trackByTrack(_: number, item: Track) { + return item.id; + } + + addTravelDuration(event: MouseEvent) { + this.clickEvent = event; + this.isFormationPopoverOpen = true; + } + + changeTime(index: number, event: InputCustomEvent) { + if (event.detail.value != null) { + const newDurations = [...this.depot.travelDurations]; + newDurations[index] = {...newDurations[index], time: +event.detail.value}; + this.depot.travelDurations = newDurations; + } + } + + onSelectFormation(event: SelectCustomEvent, index: number) { + if (event.detail.value != null) { + this.travelDurationIndex = index; + this.selectFormation(event.detail.value); + } + } + + selectFormation(formation: Formation) { + const newDurations = [...this.depot.travelDurations]; + if (this.travelDurationIndex !== undefined) { + newDurations[this.travelDurationIndex].formation = formation; + } else { + newDurations.push({ + formation: formation, + time: 0 + }); + } + this.depot.travelDurations = newDurations; + this.usedFormations = this.depot.travelDurations.map(duration => duration.formation); + this.updateUnusedFormations(); + this.travelDurationIndex = undefined; + } + + deleteTravelDuration(travelDuration: TravelDuration) { + this.depot.travelDurations = this.depot.travelDurations.filter( + duration => duration.formation.id !== travelDuration.formation.id + ); + this.usedFormations = this.depot.travelDurations.map(duration => duration.formation); + this.updateUnusedFormations(); + } + + trackByTravelDuration(_: number, item: TravelDuration) { + return item.formation.id; + } + + updateUnusedFormations() { + this.unusedFormations$ = this.formations$.pipe( + map(formations => formations.filter( + formation => !this.usedFormations.some(usedFormation => usedFormation.id == formation.id) + )), + ); + } +} diff --git a/src/app/routes/common/portal-component.ts b/src/app/routes/common/portal-component.ts new file mode 100644 index 0000000..b61e46f --- /dev/null +++ b/src/app/routes/common/portal-component.ts @@ -0,0 +1,85 @@ +import {Station} from "../model/station"; +import {InputCustomEvent, SelectCustomEvent} from "@ionic/angular"; +import {Formation} from "../../formations/model/formation"; +import {TravelDuration} from "../model/travel-duration"; +import {DEFAULT_PORTAL, Portal} from "../model/portal"; +import {FormationsStoreService} from "../../formations/service/formations-store.service"; +import {inject} from "@angular/core"; +import {map, Observable} from "rxjs"; + +export class PortalComponent { + portal: Portal = {...DEFAULT_PORTAL}; + usedFormations: Formation[] = []; + isFormationPopoverOpen = false; + clickEvent: MouseEvent = new MouseEvent('mouseup'); + travelDurationIndex?: number; + + private readonly storeService: FormationsStoreService = inject(FormationsStoreService); + readonly formations$ = this.storeService.getFormations$(); + unusedFormations$!: Observable; + + compareWithStation(station1: Station, station2: Station) { + return station1 && station2 ? station1.id === station2.id : station1 === station2; + } + + compareWithFormation(formation1: Formation, formation2: Formation) { + return formation1 && formation2 ? formation1.id == formation2.id : formation1 === formation2; + } + + changeTime(index: number, event: InputCustomEvent) { + if (event.detail.value != null) { + const newDurations = [...this.portal.travelDurations]; + newDurations[index] = {...newDurations[index], time: +event.detail.value}; + this.portal.travelDurations = newDurations; + } + } + + addTravelDuration(event: MouseEvent) { + this.clickEvent = event; + this.isFormationPopoverOpen = true; + } + + onSelectFormation(event: SelectCustomEvent, index: number) { + if (event.detail.value != null) { + this.travelDurationIndex = index; + this.selectFormation(event.detail.value); + } + } + + selectFormation(formation: Formation) { + const newDurations: TravelDuration[] = [...this.portal.travelDurations]; + + if (this.travelDurationIndex !== undefined) { + newDurations[this.travelDurationIndex] = {...newDurations[this.travelDurationIndex], formation}; + } else if (!newDurations.some(duration => duration.formation.id == formation.id)) { + newDurations.push({ + formation: formation, + time: 0 + }); + } + this.portal.travelDurations = newDurations; + this.usedFormations = this.portal.travelDurations.map(duration => duration.formation); + this.updateUnusedFormations(); + this.travelDurationIndex = undefined; + } + + deleteTravelDuration(travelDuration: TravelDuration) { + this.portal.travelDurations = this.portal.travelDurations.filter( + duration => duration.formation.id !== travelDuration.formation.id + ); + this.usedFormations = this.portal.travelDurations.map(duration => duration.formation); + this.updateUnusedFormations(); + } + + trackBy(_: number, item: TravelDuration) { + return item.formation.id; + } + + updateUnusedFormations() { + this.unusedFormations$ = this.formations$.pipe( + map(formations => formations.filter( + formation => !this.usedFormations.some(usedFormation => usedFormation.id == formation.id) + )), + ); + } +} diff --git a/src/app/routes/common/route-component.ts b/src/app/routes/common/route-component.ts new file mode 100644 index 0000000..0ed59a3 --- /dev/null +++ b/src/app/routes/common/route-component.ts @@ -0,0 +1,160 @@ +import {ItemReorderCustomEvent} from "@ionic/angular"; +import {Station} from "../model/station"; +import {DEFAULT_PORTAL, Portal} from "../model/portal"; +import {DEFAULT_ROUTE, Route} from "../model/route"; +import {DEFAULT_DEPOT, Depot} from "../model/depot"; +import {Country} from "../model/country"; +import {Item} from "../../typeahead/item"; +import {Store} from "@ngrx/store"; +import {RoutesState} from "../store/routes.reducer"; +import {inject} from "@angular/core"; +import {allCountries} from "../store"; +import {RoutesStoreService} from "../service/routes-store.service"; +import {map, Observable} from "rxjs"; + +export class RouteComponent { + route: Route = {...DEFAULT_ROUTE}; + + isStationPopoverOpen = false; + clickEvent: MouseEvent = new MouseEvent('mouseup'); + stationIndex?: number; + + isCreatePortalModalOpen = false; + isUpdatePortalModalOpen = false; + portalIndex?: number; + _updatedPortal: Portal = {...DEFAULT_PORTAL}; + + isCreateDepotModalOpen = false; + isUpdateDepotModalOpen = false; + depotIndex?: number; + _updatedDepot: Depot = {...DEFAULT_DEPOT}; + + protected readonly store: Store = inject(Store); + readonly countries$ = this.store.select(allCountries()); + private readonly storeService: RoutesStoreService = inject(RoutesStoreService); + readonly stations$ = this.storeService.getStations$(); + unusedStations$!: Observable; + + compareWithCountry(country1: Country, country2: Country) { + return country1 && country2 ? country1.code === country2.code : country1 === country2; + } + + trackBy(_: number, item: T) { + return item.id; + } + + handleReorderStations(event: ItemReorderCustomEvent) { + const newStations = [...this.route.stations]; + const movedItem = newStations[event.detail.from]; + if (event.detail.from > event.detail.to) { + for (let i = event.detail.from - 1; i >= event.detail.to; i--) { + newStations[i + 1] = newStations[i]; + } + } else { + for (let i = event.detail.from + 1; i <= event.detail.to; i++) { + newStations[i - 1] = newStations[i]; + } + } + newStations[event.detail.to] = movedItem; + this.route.stations = newStations; + this.updateUnusedStations(); + event.detail.complete(); + } + + deleteStation(deletedStation: Station) { + this.route.stations = this.route.stations.filter(station => station.id !== deletedStation.id); + this.updateUnusedStations(); + } + + selectStation(newStation: Station) { + const newStations = [...this.route.stations]; + if (this.stationIndex !== undefined) { + newStations[this.stationIndex] = newStation; + } else if (!newStations.some(station => station.id == newStation.id)) { + newStations.push(newStation); + } + this.route.stations = newStations; + this.updateUnusedStations(); + this.stationIndex = undefined; + } + + openPopoverStation(event: MouseEvent, index?: number) { + this.stationIndex = index; + this.clickEvent = event; + this.isStationPopoverOpen = true; + } + + addPortal() { + this.isCreatePortalModalOpen = true; + } + + insertPortal(portal: Portal) { + const newPortals = [...this.route.portals]; + newPortals.push(portal); + this.route.portals = newPortals; + } + + get updatedPortal() { + return this._updatedPortal; + } + + set updatedPortal(changedPortal: Portal) { + this._updatedPortal = changedPortal; + if (this.portalIndex != null) { + const newPortals = [...this.route.portals]; + newPortals[this.portalIndex] = changedPortal; + this.route.portals = newPortals; + } + } + + updatePortal(portal: Portal, index: number) { + this._updatedPortal = portal; + this.portalIndex = index; + this.isUpdatePortalModalOpen = true; + } + + deletePortal(deletedPortal: Portal) { + this.route.portals = this.route.portals.filter(portal => portal.id !== deletedPortal.id); + } + + addDepot() { + this.isCreateDepotModalOpen = true; + } + + insertDepot(depot: Depot) { + const newDepots = [...this.route.depots]; + newDepots.push(depot); + this.route.depots = newDepots; + } + + get updatedDepot() { + return this._updatedDepot; + } + + set updatedDepot(changedDepot: Depot) { + this._updatedDepot = changedDepot; + if (this.depotIndex != null) { + const newDepots = [...this.route.depots]; + newDepots[this.depotIndex] = changedDepot; + this.route.depots = newDepots; + } + } + + updateDepot(depot: Depot, index: number) { + this._updatedDepot = depot; + this.isUpdateDepotModalOpen = true; + this.depotIndex = index; + } + + deleteDepot(deletedDepot: Depot) { + this.route.depots = this.route.depots.filter(depot => depot.id !== deletedDepot.id); + } + + updateUnusedStations() { + this.unusedStations$ = this.stations$.pipe( + map(stations => stations.filter( + station => !this.route.stations.some(routeStation => routeStation.id == station.id) + )), + ); + } +} diff --git a/src/app/routes/create-depot/create-depot.component.html b/src/app/routes/create-depot/create-depot.component.html new file mode 100644 index 0000000..3a3e0ba --- /dev/null +++ b/src/app/routes/create-depot/create-depot.component.html @@ -0,0 +1,139 @@ + + + + + Create Depot + + + +
+ + + + + {{ station.name }} + + + +
Tracks
+ + + Name + Capacity + + + + + + + + + + Delete + + + + + + Add track + + +
Travel Durations to nearest station
+ + + Formation + Time (s) + + + + + + {{ travelDuration.formation.name }} + + + {{ formation.name }} + + + + + + + + Delete + + + + + + Add travel duration + +
+
+ + + + Cancel + + + Confirm + + + + +
+
+ + diff --git a/src/app/routes/create-depot/create-depot.component.scss b/src/app/routes/create-depot/create-depot.component.scss new file mode 100644 index 0000000..81d40c0 --- /dev/null +++ b/src/app/routes/create-depot/create-depot.component.scss @@ -0,0 +1,3 @@ +.bold { + font-weight: bold; +} diff --git a/src/app/routes/create-depot/create-depot.component.spec.ts b/src/app/routes/create-depot/create-depot.component.spec.ts new file mode 100644 index 0000000..4016d63 --- /dev/null +++ b/src/app/routes/create-depot/create-depot.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {CreateDepotComponent} from './create-depot.component'; + +describe('CreateDepotComponent', () => { + let component: CreateDepotComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CreateDepotComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(CreateDepotComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/routes/create-depot/create-depot.component.ts b/src/app/routes/create-depot/create-depot.component.ts new file mode 100644 index 0000000..cd61c06 --- /dev/null +++ b/src/app/routes/create-depot/create-depot.component.ts @@ -0,0 +1,100 @@ +import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core'; +import {AsyncPipe, NgForOf} from '@angular/common'; +import {DepotComponent} from "../common/depot-component"; +import {addIcons} from "ionicons"; +import {trashOutline, trashSharp} from "ionicons/icons"; +import {FormsModule} from "@angular/forms"; +import { + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonIcon, + IonInput, + IonItem, + IonItemOption, + IonItemOptions, + IonItemSliding, + IonLabel, + IonList, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar +} from "@ionic/angular/standalone"; +import {TypeaheadComponent} from "../../typeahead/typeahead.component"; +import {Station} from "../model/station"; +import {DEFAULT_DEPOT, Depot} from "../model/depot"; + +@Component({ + selector: 'app-create-depot', + standalone: true, + imports: [ + NgForOf, + FormsModule, + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonIcon, + IonInput, + IonItem, + IonItemOption, + IonItemOptions, + IonItemSliding, + IonLabel, + IonList, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar, + TypeaheadComponent, + AsyncPipe, + ], + templateUrl: './create-depot.component.html', + styleUrl: './create-depot.component.scss' +}) +export class CreateDepotComponent extends DepotComponent { + @Input({required: true}) isOpen: boolean = false; + @Output() dismissed: EventEmitter = new EventEmitter(); + @Output() createdDepot: EventEmitter = new EventEmitter(); + @ViewChild(IonModal) modal: IonModal | undefined; + + constructor() { + super(); + addIcons({ + trashOutline, + trashSharp, + }); + } + + _stations?: Station[]; + @Input({required: true}) set stations(newStations: Station[]) { + if (newStations !== null) { + this._stations = newStations; + } + } + + get stations() { + return this._stations || []; + } + + cancel() { + this.dismissed.emit(true); + this.depot = {...DEFAULT_DEPOT}; + this.usedFormations = []; + this.updateUnusedFormations(); + } + + confirm() { + this.createdDepot.emit({...this.depot}); + this.dismissed.emit(true); + this.depot = {...DEFAULT_DEPOT}; + this.usedFormations = []; + this.updateUnusedFormations(); + } +} diff --git a/src/app/routes/create-portal/create-portal.component.html b/src/app/routes/create-portal/create-portal.component.html new file mode 100644 index 0000000..61a7248 --- /dev/null +++ b/src/app/routes/create-portal/create-portal.component.html @@ -0,0 +1,111 @@ + + + + + Create Portal + + + +
+ + + + + {{ station.name }} + + + +
Travel Durations to nearest station
+ + + Formation + Time (s) + + + + + + {{ travelDuration.formation.name }} + + + {{ formation.name }} + + + + + + + + Delete + + + + + + Add travel duration + +
+
+ + + + Cancel + + + Confirm + + + + +
+
+ + diff --git a/src/app/routes/create-portal/create-portal.component.scss b/src/app/routes/create-portal/create-portal.component.scss new file mode 100644 index 0000000..81d40c0 --- /dev/null +++ b/src/app/routes/create-portal/create-portal.component.scss @@ -0,0 +1,3 @@ +.bold { + font-weight: bold; +} diff --git a/src/app/routes/create-portal/create-portal.component.spec.ts b/src/app/routes/create-portal/create-portal.component.spec.ts new file mode 100644 index 0000000..e4df9b0 --- /dev/null +++ b/src/app/routes/create-portal/create-portal.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {CreatePortalComponent} from './create-portal.component'; + +describe('CreatePortalComponent', () => { + let component: CreatePortalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CreatePortalComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(CreatePortalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/routes/create-portal/create-portal.component.ts b/src/app/routes/create-portal/create-portal.component.ts new file mode 100644 index 0000000..1b9126b --- /dev/null +++ b/src/app/routes/create-portal/create-portal.component.ts @@ -0,0 +1,84 @@ +import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core'; +import {AsyncPipe, NgForOf} from '@angular/common'; +import {FormsModule, ReactiveFormsModule} from "@angular/forms"; +import { + IonButton, + IonButtons, + IonContent, + IonDatetime, + IonFooter, + IonHeader, + IonIcon, + IonInput, + IonItem, + IonItemOption, + IonItemOptions, + IonItemSliding, + IonLabel, + IonList, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar +} from "@ionic/angular/standalone"; +import {DEFAULT_PORTAL, Portal} from "../model/portal"; +import {Station} from "../model/station"; +import {TypeaheadComponent} from "../../typeahead/typeahead.component"; +import {PortalComponent} from "../common/portal-component"; +import {addIcons} from "ionicons"; +import {trashOutline, trashSharp} from "ionicons/icons"; + +@Component({ + selector: 'app-create-portal', + standalone: true, + imports: [ + FormsModule, IonContent, IonHeader, IonInput, IonModal, IonTitle, IonToolbar, + ReactiveFormsModule, IonSelect, IonSelectOption, IonButton, IonButtons, IonFooter, IonIcon, IonItem, IonItemOption, + IonItemOptions, IonItemSliding, IonLabel, IonList, TypeaheadComponent, IonDatetime, NgForOf, AsyncPipe + ], + templateUrl: './create-portal.component.html', + styleUrl: './create-portal.component.scss' +}) +export class CreatePortalComponent extends PortalComponent { + @Input({required: true}) isOpen: boolean = false; + @Output() dismissed: EventEmitter = new EventEmitter(); + @Output() createdPortal: EventEmitter = new EventEmitter(); + @ViewChild(IonModal) modal: IonModal | undefined; + + constructor() { + super(); + addIcons({ + trashOutline, + trashSharp, + }); + } + + _stations?: Station[]; + @Input({required: true}) set stations(newStations: Station[]) { + if (newStations !== null) { + this._stations = newStations; + } + } + + get stations() { + return this._stations || []; + } + + cancel() { + this.dismissed.emit(true); + this.portal = {...DEFAULT_PORTAL}; + this.usedFormations = []; + this.updateUnusedFormations(); + } + + confirm() { + this.createdPortal.emit({...this.portal}); + this.dismissed.emit(true); + this.portal = {...DEFAULT_PORTAL}; + this.usedFormations = []; + this.updateUnusedFormations(); + } + + +} diff --git a/src/app/routes/create-route/create-route.component.html b/src/app/routes/create-route/create-route.component.html new file mode 100644 index 0000000..c89d944 --- /dev/null +++ b/src/app/routes/create-route/create-route.component.html @@ -0,0 +1,148 @@ + + + + + Create Route + + + +
+ + + + + {{ country.name }} + + +
Stations
+ + + + + + {{ station.name }} + + + + + Delete + + + + + + + Add station + + +
Portals
+ + + + {{ portal.name }} + + + + + Update + + + + Delete + + + + + + Add portal + + +
Depots
+ + + + {{ depot.name }} + + + + + Update + + + + Delete + + + + + + Add depot + +
+
+ + + + Cancel + + + Confirm + + + + +
+
+ + + + + + + + diff --git a/src/app/routes/create-route/create-route.component.scss b/src/app/routes/create-route/create-route.component.scss new file mode 100644 index 0000000..5bca3d2 --- /dev/null +++ b/src/app/routes/create-route/create-route.component.scss @@ -0,0 +1,3 @@ +.no-pointer-events { + pointer-events: none; +} diff --git a/src/app/routes/create-route/create-route.component.spec.ts b/src/app/routes/create-route/create-route.component.spec.ts new file mode 100644 index 0000000..13d2ee0 --- /dev/null +++ b/src/app/routes/create-route/create-route.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {CreateRouteComponent} from './create-route.component'; + +describe('CreateRouteComponent', () => { + let component: CreateRouteComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CreateRouteComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(CreateRouteComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/routes/create-route/create-route.component.ts b/src/app/routes/create-route/create-route.component.ts new file mode 100644 index 0000000..30254dd --- /dev/null +++ b/src/app/routes/create-route/create-route.component.ts @@ -0,0 +1,113 @@ +import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core'; +import { + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonIcon, + IonInput, + IonItem, + IonItemOption, + IonItemOptions, + IonItemSliding, + IonLabel, + IonList, + IonListHeader, + IonModal, + IonPopover, + IonReorder, + IonReorderGroup, + IonSearchbar, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar +} from "@ionic/angular/standalone"; +import {DEFAULT_ROUTE} from "../model/route"; +import {addRouteAction} from "../store/routes.actions"; +import {AsyncPipe, NgForOf} from "@angular/common"; +import {FormsModule, ReactiveFormsModule} from "@angular/forms"; +import {TypeaheadComponent} from "../../typeahead/typeahead.component"; +import {addIcons} from "ionicons"; +import {pencilOutline, pencilSharp, trashOutline, trashSharp} from "ionicons/icons"; +import {CreatePortalComponent} from "../create-portal/create-portal.component"; +import {RouteComponent} from "../common/route-component"; +import {UpdatePortalComponent} from "../update-portal/update-portal.component"; +import {CreateDepotComponent} from "../create-depot/create-depot.component"; +import {UpdateDepotComponent} from "../update-depot/update-depot.component"; + +@Component({ + selector: 'app-create-route', + standalone: true, + templateUrl: './create-route.component.html', + imports: [ + AsyncPipe, + FormsModule, + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonInput, + IonItem, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar, + NgForOf, + ReactiveFormsModule, + IonList, + IonReorderGroup, + IonLabel, + IonReorder, + IonListHeader, + IonPopover, + IonSearchbar, + IonItemSliding, + IonItemOption, + IonItemOptions, + IonIcon, + TypeaheadComponent, + CreatePortalComponent, + UpdatePortalComponent, + CreateDepotComponent, + UpdateDepotComponent + ], + styleUrl: './create-route.component.scss' +}) +export class CreateRouteComponent extends RouteComponent { + @Input() isOpen: boolean = false; + @Output() dismissed: EventEmitter = new EventEmitter(); + + @ViewChild(IonModal) modal: IonModal | undefined; + + constructor() { + super(); + addIcons({ + trashOutline, + trashSharp, + pencilOutline, + pencilSharp, + }); + } + + cancel() { + this.dismissed.emit(true); + this.route = {...DEFAULT_ROUTE}; + this.updateUnusedStations(); + } + + confirm() { + this.route.firstStation = this.route.stations[0]; + this.route.lastStation = this.route.stations[this.route.stations.length - 1]; + this.route.numberOfStations = this.route.stations.length; + this.store.dispatch(addRouteAction({ + payload: {...this.route} + })) + this.dismissed.emit(true); + this.route = {...DEFAULT_ROUTE}; + this.updateUnusedStations(); + } +} diff --git a/src/app/routes/model/depot.ts b/src/app/routes/model/depot.ts new file mode 100644 index 0000000..1fa5c60 --- /dev/null +++ b/src/app/routes/model/depot.ts @@ -0,0 +1,25 @@ +import {Item} from "../../typeahead/item"; +import {DEFAULT_STATION, Station} from "./station"; +import {TravelDuration} from "./travel-duration"; + +export interface Depot extends Item { + id: string; + name: string; + nearestStation: Station; + tracks: Track[]; + travelDurations: TravelDuration[]; +} + +export interface Track { + id: number; + name: string; + capacity: number; +} + +export const DEFAULT_DEPOT: Depot = { + id: '', + name: '', + nearestStation: DEFAULT_STATION, + tracks: [], + travelDurations: [] +}; diff --git a/src/app/routes/model/portal.ts b/src/app/routes/model/portal.ts new file mode 100644 index 0000000..ff62232 --- /dev/null +++ b/src/app/routes/model/portal.ts @@ -0,0 +1,17 @@ +import {Item} from "../../typeahead/item"; +import {DEFAULT_STATION, Station} from "./station"; +import {TravelDuration} from "./travel-duration"; + +export interface Portal extends Item { + id: string; + name: string; + nearestStation: Station; + travelDurations: TravelDuration[]; +} + +export const DEFAULT_PORTAL: Portal = { + id: '', + name: '', + nearestStation: DEFAULT_STATION, + travelDurations: [] +}; diff --git a/src/app/routes/model/route.ts b/src/app/routes/model/route.ts index 04916a7..38928bf 100644 --- a/src/app/routes/model/route.ts +++ b/src/app/routes/model/route.ts @@ -1,14 +1,31 @@ -import {Station} from "./station"; +import {DEFAULT_STATION, Station} from "./station"; import {Country} from "./country"; +import {Depot} from "./depot"; +import {Portal} from "./portal"; export interface Route { + id: string; name: string; country: Country; + stations: Station[]; firstStation: Station; lastStation: Station; numberOfStations: number; + depots: Depot[]; + portals: Portal[]; } -export interface EditedRoute extends Route { - stations: Station[]; +export const DEFAULT_ROUTE: Route = { + id: "", + name: "", + country: { + code: 'de', + name: 'Germany' + }, + firstStation: DEFAULT_STATION, + lastStation: DEFAULT_STATION, + stations: [], + numberOfStations: 0, + depots: [], + portals: [] } diff --git a/src/app/routes/model/station.ts b/src/app/routes/model/station.ts index f3d7aff..d40c6f1 100644 --- a/src/app/routes/model/station.ts +++ b/src/app/routes/model/station.ts @@ -1,3 +1,11 @@ -export interface Station { +import {Item} from "../../typeahead/item"; + +export interface Station extends Item { + id: string; name: string; } + +export const DEFAULT_STATION: Station = { + id: '', + name: '' +}; diff --git a/src/app/routes/model/travel-duration.ts b/src/app/routes/model/travel-duration.ts new file mode 100644 index 0000000..b44b25d --- /dev/null +++ b/src/app/routes/model/travel-duration.ts @@ -0,0 +1,6 @@ +import {Formation} from "../../formations/model/formation"; + +export interface TravelDuration { + formation: Formation; + time: number; +} diff --git a/src/app/routes/routes.component.html b/src/app/routes/routes.component.html index 1ec54ef..1b0165e 100644 --- a/src/app/routes/routes.component.html +++ b/src/app/routes/routes.component.html @@ -3,7 +3,12 @@ - Routes + + Routes + @if (!(hasPersonalPlan$ | async)) { + ({{ (routes$ | async)?.length }} / 1) + } + @@ -15,8 +20,8 @@ Last Station # Stations - - + + {{ route.name }} {{ route.country.name }} {{ route.firstStation.name }} @@ -24,17 +29,26 @@ {{ route.numberOfStations }} - + + + Update + + Delete - - - - - + @if ((routes$ | async)?.length == 0 || (hasPersonalPlan$ | async)) { + + + + + + } + + - + diff --git a/src/app/routes/routes.component.ts b/src/app/routes/routes.component.ts index 6cb39cc..664a457 100644 --- a/src/app/routes/routes.component.ts +++ b/src/app/routes/routes.component.ts @@ -1,8 +1,8 @@ -import {Component} from '@angular/core'; -import {Route} from "./model/route"; +import {Component, inject} from '@angular/core'; +import {DEFAULT_ROUTE, Route} from "./model/route"; import {addIcons} from "ionicons"; -import {addOutline, addSharp, trashOutline, trashSharp} from "ionicons/icons"; -import {NgForOf} from "@angular/common"; +import {addOutline, addSharp, pencilOutline, pencilSharp, trashOutline, trashSharp} from "ionicons/icons"; +import {AsyncPipe, NgForOf} from "@angular/common"; import { IonButtons, IonContent, @@ -22,6 +22,16 @@ import { IonTitle, IonToolbar } from "@ionic/angular/standalone"; +import {RoutesStoreService} from "./service/routes-store.service"; +import {deleteRouteAction} from "./store/routes.actions"; +import {Store} from "@ngrx/store"; +import {RoutesState} from "./store/routes.reducer"; +import {CreateFormationComponent} from "../formations/create-formation/create-formation.component"; +import {UpdateFormationComponent} from "../formations/update-formation/update-formation.component"; +import {CreateRouteComponent} from "./create-route/create-route.component"; +import {UpdateRouteComponent} from "./update-route/update-route.component"; +import {AuthService} from "../auth/service/auth.service"; +import {filter, map} from "rxjs"; @Component({ selector: 'app-routes', @@ -46,27 +56,50 @@ import { IonIcon, IonFab, IonFabButton, - IonFooter + IonFooter, + AsyncPipe, + CreateFormationComponent, + UpdateFormationComponent, + CreateRouteComponent, + UpdateRouteComponent ] }) export class RoutesComponent { + isCreateModalOpen = false; + isUpdateModalOpen = false; + updatedRoute: Route = {...DEFAULT_ROUTE}; - routes: Route[] = [ - { - name: 'Köln-Aachen', - country: {name: $localize`Germany`, code: 'de'}, - firstStation: {name: 'Köln Hbf'}, - lastStation: {name: 'Aachen Hbf'}, - numberOfStations: 30 - } - ]; + private readonly storeService: RoutesStoreService = inject(RoutesStoreService); + routes$ = this.storeService.getRoutes$(); - constructor() { + private readonly authService: AuthService = inject(AuthService); + readonly user$ = this.authService.getUser$(); + readonly hasPersonalPlan$ = this.user$.pipe( + map(user => user.roles), + filter(roles => roles.includes('PERSONAL_PLAN')) + ); + + constructor(private readonly store: Store) { addIcons({ addOutline, addSharp, trashOutline, trashSharp, + pencilOutline, + pencilSharp, }); } + + addRoute() { + this.isCreateModalOpen = true; + } + + updateRoute(route: Route) { + this.updatedRoute = route; + this.isUpdateModalOpen = true; + } + + deleteRoute(route: Route) { + this.store.dispatch(deleteRouteAction({payload: route})); + } } diff --git a/src/app/routes/service/route.service.spec.ts b/src/app/routes/service/route.service.spec.ts new file mode 100644 index 0000000..3cf94cf --- /dev/null +++ b/src/app/routes/service/route.service.spec.ts @@ -0,0 +1,16 @@ +import {TestBed} from '@angular/core/testing'; + +import {RouteService} from './route.service'; + +describe('RoutesService', () => { + let service: RouteService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(RouteService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/routes/service/route.service.ts b/src/app/routes/service/route.service.ts new file mode 100644 index 0000000..526aac0 --- /dev/null +++ b/src/app/routes/service/route.service.ts @@ -0,0 +1,75 @@ +import {Injectable} from '@angular/core'; +import {HttpClient, HttpHeaders} from "@angular/common/http"; +import {environment} from "../../../environments/environment"; +import {Route} from "../model/route"; +import {ErrorService} from "../../errors/error.service"; +import {catchError, Observable, of} from "rxjs"; + +@Injectable({ + providedIn: 'root' +}) +export class RouteService { + + httpOptions = { + headers: new HttpHeaders({'Content-Type': 'application/json'}) + }; + private routesURL = environment.backendURL + '/route'; + + private knownRoutes: Map = new Map(); + private routes: Route[] = []; + + constructor(private readonly http: HttpClient, + private readonly errorService: ErrorService) { + } + + fetchRoutes(): Observable { + if (environment.mockNetwork) { + this.routes = JSON.parse(localStorage.getItem("routes") || '[]'); + return of(this.routes); + } + + return this.http.get(this.routesURL, this.httpOptions) + .pipe( + catchError(this.errorService.handleError('Routes', + 'fetchRoutes', [])) + ); + } + + storeRoute(route: Route): Observable { + if (environment.mockNetwork) { + this.knownRoutes.set(route.id, route); + this.storeRoutesInLocalStorage(); + return of(route); + } + + return this.http.put( + this.routesURL + '/' + encodeURIComponent(route.id), + route, + this.httpOptions + ).pipe( + catchError(this.errorService.handleError('Routes', + 'storeRoute', route)) + ) + } + + deleteRoute(route: Route): Observable { + if (environment.mockNetwork) { + this.knownRoutes.delete(route.id); + this.storeRoutesInLocalStorage(); + return of(new ArrayBuffer(0)); + } + + return this.http.delete( + this.routesURL + '/' + encodeURIComponent(route.id), + this.httpOptions + ).pipe( + catchError(this.errorService.handleError('Route', + 'deleteRoute', new ArrayBuffer(0))) + ) + } + + private storeRoutesInLocalStorage() { + this.routes = Array.from(this.knownRoutes.values()); + localStorage.setItem("routes", JSON.stringify(this.routes)); + } +} diff --git a/src/app/routes/service/routes-store.service.spec.ts b/src/app/routes/service/routes-store.service.spec.ts new file mode 100644 index 0000000..ca9dfdc --- /dev/null +++ b/src/app/routes/service/routes-store.service.spec.ts @@ -0,0 +1,16 @@ +import {TestBed} from '@angular/core/testing'; + +import {RoutesStoreService} from './routes-store.service'; + +describe('RoutesStoreService', () => { + let service: RoutesStoreService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(RoutesStoreService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/routes/service/routes-store.service.ts b/src/app/routes/service/routes-store.service.ts new file mode 100644 index 0000000..80cd683 --- /dev/null +++ b/src/app/routes/service/routes-store.service.ts @@ -0,0 +1,64 @@ +import {Injectable} from '@angular/core'; +import {Store} from "@ngrx/store"; +import {RouteService} from "./route.service"; +import {RoutesState} from "../store/routes.reducer"; +import {filter, finalize, Observable, share, switchMap, tap, using} from "rxjs"; +import {allRoutes, allStations, needRoutes, needStations} from "../store"; +import { + loadAllRoutesAction, + loadAllRoutesCancelledAction, + loadAllRoutesFinishedAction, + loadAllStationsAction, + loadAllStationsCancelledAction, + loadAllStationsFinishedAction +} from "../store/routes.actions"; +import {Route} from "../model/route"; +import {Station} from "../model/station"; +import {StationService} from "./station.service"; + +@Injectable({ + providedIn: 'root' +}) +export class RoutesStoreService { + + constructor(private readonly routeService: RouteService, + private readonly stationService: StationService, + private readonly store: Store) { + } + + getRoutes$() { + return using( + () => this.loadRoutes$().subscribe(), + () => this.store.select(allRoutes()) + ) + } + + private loadRoutes$(): Observable { + return this.store.select(needRoutes()).pipe( + filter(needRoutes => needRoutes), + tap(() => this.store.dispatch(loadAllRoutesAction())), + switchMap(() => this.routeService.fetchRoutes()), + tap((routes) => this.store.dispatch(loadAllRoutesFinishedAction({payload: routes}))), + finalize(() => this.store.dispatch(loadAllRoutesCancelledAction())), + share() + ); + } + + getStations$() { + return using( + () => this.loadStations$().subscribe(), + () => this.store.select(allStations()) + ) + } + + private loadStations$(): Observable { + return this.store.select(needStations()).pipe( + filter(needStations => needStations), + tap(() => this.store.dispatch(loadAllStationsAction())), + switchMap(() => this.stationService.fetchStations()), + tap((stations) => this.store.dispatch(loadAllStationsFinishedAction({payload: stations}))), + finalize(() => this.store.dispatch(loadAllStationsCancelledAction())), + share() + ); + } +} diff --git a/src/app/routes/service/station.service.spec.ts b/src/app/routes/service/station.service.spec.ts new file mode 100644 index 0000000..85d6430 --- /dev/null +++ b/src/app/routes/service/station.service.spec.ts @@ -0,0 +1,16 @@ +import {TestBed} from '@angular/core/testing'; + +import {StationService} from './station.service'; + +describe('StationService', () => { + let service: StationService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(StationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/routes/service/station.service.ts b/src/app/routes/service/station.service.ts new file mode 100644 index 0000000..16e85a5 --- /dev/null +++ b/src/app/routes/service/station.service.ts @@ -0,0 +1,38 @@ +import {Injectable} from '@angular/core'; +import {HttpClient, HttpHeaders} from "@angular/common/http"; +import {environment} from "../../../environments/environment"; +import {ErrorService} from "../../errors/error.service"; +import {catchError, Observable, of} from "rxjs"; +import {Route} from "../model/route"; +import {Station} from "../model/station"; + +@Injectable({ + providedIn: 'root' +}) +export class StationService { + + httpOptions = { + headers: new HttpHeaders({'Content-Type': 'application/json'}) + }; + private stationsURL = environment.backendURL + '/station'; + + private knownStations: Map = new Map(); + private stations: Station[] = []; + + constructor(private readonly http: HttpClient, + private readonly errorService: ErrorService) { + } + + fetchStations(): Observable { + if (environment.mockNetwork) { + this.stations = JSON.parse(localStorage.getItem("stations") || '[]'); + return of(this.stations); + } + + return this.http.get(this.stationsURL, this.httpOptions) + .pipe( + catchError(this.errorService.handleError('Routes', + 'fetchStations', [])) + ); + } +} diff --git a/src/app/routes/store/index.ts b/src/app/routes/store/index.ts new file mode 100644 index 0000000..4e3dceb --- /dev/null +++ b/src/app/routes/store/index.ts @@ -0,0 +1,40 @@ +import {createFeatureSelector, createSelector} from "@ngrx/store"; +import {RoutesState} from "./routes.reducer"; +import {FunctionalEffect} from "@ngrx/effects"; +import {deleteRoute, storeRoute} from "./routes.effects"; + +export const featureStateName = 'routes'; + +export const routesEffects: Record = { + storeRoute: storeRoute, + deleteRoute: deleteRoute +} + +export const getRoutesFeatureState = createFeatureSelector( + featureStateName +); + +export const needRoutes = () => createSelector( + getRoutesFeatureState, + (state: RoutesState) => state.needRoutes +); + +export const allRoutes = () => createSelector( + getRoutesFeatureState, + (state: RoutesState) => state.routes +); + +export const allCountries = () => createSelector( + getRoutesFeatureState, + (state: RoutesState) => state.countries +); + +export const needStations = () => createSelector( + getRoutesFeatureState, + (state: RoutesState) => state.needStations +); + +export const allStations = () => createSelector( + getRoutesFeatureState, + (state: RoutesState) => state.stations +); diff --git a/src/app/routes/store/routes.actions.ts b/src/app/routes/store/routes.actions.ts new file mode 100644 index 0000000..4308322 --- /dev/null +++ b/src/app/routes/store/routes.actions.ts @@ -0,0 +1,71 @@ +import {createAction, props} from "@ngrx/store"; +import {Route} from "../model/route"; +import {Station} from "../model/station"; + +export enum ActionTypes { + LoadAllRoutes = '[Routes] Load All Routes', + LoadAllRoutesFinished = '[Routes] Load All Routes Finished', + LoadAllRoutesCancelled = '[Routes] Load All Routes Cancelled', + + LoadAllStations = '[Routes] Load All Stations', + LoadAllStationsFinished = '[Routes] Load All Stations Finished', + LoadAllStationsCancelled = '[Routes] Load All Stations Cancelled', + + LoadSingleRoute = '[Routes] Load Single Route', + LoadSingleRouteFinished = '[Routes] Load Single Route Finished', + + AddRoute = '[Routes] Add Route', + UpdateRoute = '[Routes] Update Route', + DeleteRoute = '[Routes] Delete Route', +} + +export const loadAllRoutesAction = createAction( + ActionTypes.LoadAllRoutes +); + +export const loadAllRoutesFinishedAction = createAction( + ActionTypes.LoadAllRoutesFinished, + props<{ payload: Route[] }>() +); + +export const loadAllRoutesCancelledAction = createAction( + ActionTypes.LoadAllRoutesCancelled +); + +export const loadSingleRouteAction = createAction( + ActionTypes.LoadSingleRoute, + props<{ payload: string }>() +); + +export const loadSingleRouteFinishedAction = createAction( + ActionTypes.LoadSingleRouteFinished, + props<{ payload: Route }>() +); + +export const addRouteAction = createAction( + ActionTypes.AddRoute, + props<{ payload: Route }>() +); + +export const updateRouteAction = createAction( + ActionTypes.UpdateRoute, + props<{ payload: Route }>() +); + +export const deleteRouteAction = createAction( + ActionTypes.DeleteRoute, + props<{ payload: Route }>() +); + +export const loadAllStationsAction = createAction( + ActionTypes.LoadAllStations +); + +export const loadAllStationsFinishedAction = createAction( + ActionTypes.LoadAllStationsFinished, + props<{ payload: Station[] }>() +); + +export const loadAllStationsCancelledAction = createAction( + ActionTypes.LoadAllStationsCancelled +); diff --git a/src/app/routes/store/routes.effects.ts b/src/app/routes/store/routes.effects.ts new file mode 100644 index 0000000..ca7e949 --- /dev/null +++ b/src/app/routes/store/routes.effects.ts @@ -0,0 +1,29 @@ +import {Actions, createEffect, ofType} from "@ngrx/effects"; +import {inject} from "@angular/core"; +import {RouteService} from "../service/route.service"; +import {addRouteAction, deleteRouteAction, updateRouteAction} from "./routes.actions"; +import {map, switchMap} from "rxjs"; + +export const storeRoute = createEffect(( + actions$ = inject(Actions), + routesService = inject(RouteService) + ) => { + return actions$.pipe( + ofType(addRouteAction, updateRouteAction), + map(action => action.payload), + switchMap((route) => routesService.storeRoute(route)) + ); + }, + {functional: true, dispatch: false}); + +export const deleteRoute = createEffect(( + actions$ = inject(Actions), + routesService = inject(RouteService) + ) => { + return actions$.pipe( + ofType(deleteRouteAction), + map(action => action.payload), + switchMap((route) => routesService.deleteRoute(route)) + ); + }, + {functional: true, dispatch: false}); diff --git a/src/app/routes/store/routes.reducer.ts b/src/app/routes/store/routes.reducer.ts new file mode 100644 index 0000000..279c5e3 --- /dev/null +++ b/src/app/routes/store/routes.reducer.ts @@ -0,0 +1,82 @@ +import {Route} from "../model/route"; +import {createReducer, on} from "@ngrx/store"; +import { + addRouteAction, + deleteRouteAction, + loadAllRoutesCancelledAction, + loadAllRoutesFinishedAction, + loadAllStationsCancelledAction, + loadAllStationsFinishedAction, + loadSingleRouteFinishedAction, + updateRouteAction +} from "./routes.actions"; +import {Country} from "../model/country"; +import {Station} from "../model/station"; + +export interface RoutesState { + needRoutes: boolean; + routes: Route[]; + countries: Country[]; + needStations: boolean; + stations: Station[]; +} + +export const initialState: RoutesState = { + needRoutes: true, + routes: [], + countries: [ + { + code: 'de', + name: $localize`Germany` + } + ], + needStations: true, + stations: [] +}; + +export const routesReducer = createReducer( + initialState, + on(loadAllRoutesFinishedAction, (state, + action) => ({ + ...state, + routes: [...action.payload] + })), + on(loadSingleRouteFinishedAction, (state, + action) => ({ + ...state, + selectedItem: action.payload + })), + on(loadAllRoutesFinishedAction, loadAllRoutesCancelledAction, (state, _) => ({ + ...state, + needRoutes: false + })), + on(loadAllStationsFinishedAction, (state, + action) => ({ + ...state, + stations: [...action.payload] + })), + on(loadAllStationsFinishedAction, loadAllStationsCancelledAction, (state, _) => ({ + ...state, + needStations: false + })), + on(addRouteAction, (state, action) => ({ + ...state, + routes: [...state.routes, action.payload] + })), + on(updateRouteAction, (state, action) => ({ + ...state, + routes: state.routes.map((oldRoute) => { + if (oldRoute.id == action.payload.id) { + return action.payload; + } else { + return oldRoute; + } + }) + })), + on(deleteRouteAction, (state, action) => ({ + ...state, + routes: state.routes.filter((route) => { + return route.id != action.payload.id + }) + })) +); diff --git a/src/app/routes/update-depot/update-depot.component.html b/src/app/routes/update-depot/update-depot.component.html new file mode 100644 index 0000000..51a055d --- /dev/null +++ b/src/app/routes/update-depot/update-depot.component.html @@ -0,0 +1,133 @@ + + + + + Update Depot + + + +
+ + + + + {{ station.name }} + + + +
Tracks
+ + + Name + Capacity + + + + + + + + + + Delete + + + + + + Add track + + +
Travel Durations to nearest station
+ + + Formation + Time (s) + + + + + + {{ travelDuration.formation.name }} + + + {{ formation.name }} + + + + + + + + Delete + + + + + + Add travel duration + +
+
+ + + + Cancel + + + Confirm + + + + +
+
+ + diff --git a/src/app/routes/update-depot/update-depot.component.scss b/src/app/routes/update-depot/update-depot.component.scss new file mode 100644 index 0000000..81d40c0 --- /dev/null +++ b/src/app/routes/update-depot/update-depot.component.scss @@ -0,0 +1,3 @@ +.bold { + font-weight: bold; +} diff --git a/src/app/routes/update-depot/update-depot.component.spec.ts b/src/app/routes/update-depot/update-depot.component.spec.ts new file mode 100644 index 0000000..c9c3398 --- /dev/null +++ b/src/app/routes/update-depot/update-depot.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {UpdateDepotComponent} from './update-depot.component'; + +describe('UpdateDepotComponent', () => { + let component: UpdateDepotComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UpdateDepotComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UpdateDepotComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/routes/update-depot/update-depot.component.ts b/src/app/routes/update-depot/update-depot.component.ts new file mode 100644 index 0000000..7652c51 --- /dev/null +++ b/src/app/routes/update-depot/update-depot.component.ts @@ -0,0 +1,84 @@ +import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {DepotComponent} from "../common/depot-component"; +import {FormsModule} from "@angular/forms"; +import { + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonIcon, + IonInput, + IonItem, + IonItemOption, + IonItemOptions, + IonItemSliding, + IonLabel, + IonList, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar +} from "@ionic/angular/standalone"; +import {TypeaheadComponent} from "../../typeahead/typeahead.component"; +import {addIcons} from "ionicons"; +import {trashOutline, trashSharp} from "ionicons/icons"; +import {Station} from "../model/station"; +import {Depot} from "../model/depot"; + +@Component({ + selector: 'app-update-depot', + standalone: true, + imports: [CommonModule, FormsModule, IonButton, IonButtons, IonContent, IonFooter, IonHeader, IonIcon, IonInput, IonItem, IonItemOption, IonItemOptions, IonItemSliding, IonLabel, IonList, IonModal, IonSelect, IonSelectOption, IonTitle, IonToolbar, TypeaheadComponent], + templateUrl: './update-depot.component.html', + styleUrl: './update-depot.component.scss' +}) +export class UpdateDepotComponent extends DepotComponent { + @Input({required: true}) isOpen: boolean = false; + @Output() dismissed: EventEmitter = new EventEmitter(); + @Output() updatedDepotChange: EventEmitter = new EventEmitter(); + + @ViewChild(IonModal) modal: IonModal | undefined; + + depotOnOpen: Depot = {...this.depot}; + + constructor() { + super(); + addIcons({ + trashOutline, + trashSharp, + }); + } + + @Input({required: true}) set updatedDepot(newValue: Depot) { + this.depot = {...newValue}; + this.depotOnOpen = {...newValue}; + this.usedFormations = this.depot.travelDurations.map(duration => duration.formation); + this.updateUnusedFormations(); + } + + _stations?: Station[]; + @Input({required: true}) set stations(newStations: Station[]) { + if (newStations !== null) { + this._stations = newStations; + } + } + + get stations() { + return this._stations || []; + } + + cancel() { + this.dismissed.emit(true); + this.depot = this.depotOnOpen; + this.usedFormations = this.depot.travelDurations.map(duration => duration.formation); + this.updateUnusedFormations(); + } + + confirm() { + this.updatedDepotChange.emit({...this.depot}); + this.dismissed.emit(true); + } +} diff --git a/src/app/routes/update-portal/update-portal.component.html b/src/app/routes/update-portal/update-portal.component.html new file mode 100644 index 0000000..240d45b --- /dev/null +++ b/src/app/routes/update-portal/update-portal.component.html @@ -0,0 +1,107 @@ + + + + + Update Portal + + + +
+ + + + + {{ station.name }} + + + +
Travel Durations to nearest station
+ + + Formation + Time (s) + + + + + + {{ travelDuration.formation.name }} + + + {{ formation.name }} + + + + + + + + Delete + + + + + + Add travel duration + +
+
+ + + + Cancel + + + Confirm + + + + +
+
+ + diff --git a/src/app/routes/update-portal/update-portal.component.scss b/src/app/routes/update-portal/update-portal.component.scss new file mode 100644 index 0000000..81d40c0 --- /dev/null +++ b/src/app/routes/update-portal/update-portal.component.scss @@ -0,0 +1,3 @@ +.bold { + font-weight: bold; +} diff --git a/src/app/routes/update-portal/update-portal.component.spec.ts b/src/app/routes/update-portal/update-portal.component.spec.ts new file mode 100644 index 0000000..06c41a8 --- /dev/null +++ b/src/app/routes/update-portal/update-portal.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {UpdatePortalComponent} from './update-portal.component'; + +describe('UpdatePortalComponent', () => { + let component: UpdatePortalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UpdatePortalComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UpdatePortalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/routes/update-portal/update-portal.component.ts b/src/app/routes/update-portal/update-portal.component.ts new file mode 100644 index 0000000..189ab58 --- /dev/null +++ b/src/app/routes/update-portal/update-portal.component.ts @@ -0,0 +1,108 @@ +import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core'; +import {PortalComponent} from "../common/portal-component"; +import {addIcons} from "ionicons"; +import {trashOutline, trashSharp} from "ionicons/icons"; +import {AsyncPipe, NgForOf} from "@angular/common"; +import {FormsModule, ReactiveFormsModule} from "@angular/forms"; +import { + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonIcon, + IonInput, + IonItem, + IonItemOption, + IonItemOptions, + IonItemSliding, + IonLabel, + IonList, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar +} from "@ionic/angular/standalone"; +import {TypeaheadComponent} from "../../typeahead/typeahead.component"; +import {Portal} from "../model/portal"; +import {Station} from "../model/station"; + +@Component({ + selector: 'app-update-portal', + standalone: true, + imports: [ + NgForOf, + FormsModule, + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonIcon, + IonInput, + IonItem, + IonItemOption, + IonItemOptions, + IonItemSliding, + IonLabel, + IonList, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar, + ReactiveFormsModule, + TypeaheadComponent, + AsyncPipe, + ], + templateUrl: './update-portal.component.html', + styleUrl: './update-portal.component.scss' +}) +export class UpdatePortalComponent extends PortalComponent { + @Input({required: true}) isOpen: boolean = false; + @Output() dismissed: EventEmitter = new EventEmitter(); + @Output() updatedPortalChange: EventEmitter = new EventEmitter(); + + @ViewChild(IonModal) modal: IonModal | undefined; + + portalOnOpen: Portal = {...this.portal}; + + constructor() { + super(); + addIcons({ + trashOutline, + trashSharp, + }); + } + + @Input({required: true}) set updatedPortal(newValue: Portal) { + this.portal = {...newValue}; + this.portalOnOpen = {...newValue}; + this.usedFormations = this.portal.travelDurations.map(duration => duration.formation); + this.updateUnusedFormations(); + } + + _stations?: Station[]; + @Input({required: true}) set stations(newStations: Station[]) { + if (newStations !== null) { + this._stations = newStations; + } + } + + get stations() { + return this._stations || []; + } + + cancel() { + this.dismissed.emit(true); + this.portal = this.portalOnOpen; + this.usedFormations = this.portal.travelDurations.map(duration => duration.formation); + this.updateUnusedFormations(); + } + + confirm() { + this.updatedPortalChange.emit(this.portal); + this.dismissed.emit(true); + } +} diff --git a/src/app/routes/update-route/update-route.component.html b/src/app/routes/update-route/update-route.component.html new file mode 100644 index 0000000..a0e26bd --- /dev/null +++ b/src/app/routes/update-route/update-route.component.html @@ -0,0 +1,142 @@ + + + + + Update Route + + + +
+ + + + + {{ country.name }} + + +
Stations
+ + + + + + {{ station.name }} + + + + + Delete + + + + + + + Add station + + +
Portals
+ + + + {{ portal.name }} + + + + + Update + + + + Delete + + + + + + Add portal + + +
Depots
+ + + + {{ depot.name }} + + + + + Update + + + + Delete + + + + + + Add depot + +
+
+ + + + Cancel + + + Confirm + + + + +
+
+ + + + + + + + diff --git a/src/app/routes/update-route/update-route.component.scss b/src/app/routes/update-route/update-route.component.scss new file mode 100644 index 0000000..5bca3d2 --- /dev/null +++ b/src/app/routes/update-route/update-route.component.scss @@ -0,0 +1,3 @@ +.no-pointer-events { + pointer-events: none; +} diff --git a/src/app/routes/update-route/update-route.component.spec.ts b/src/app/routes/update-route/update-route.component.spec.ts new file mode 100644 index 0000000..ed1d166 --- /dev/null +++ b/src/app/routes/update-route/update-route.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {UpdateRouteComponent} from './update-route.component'; + +describe('UpdateRouteComponent', () => { + let component: UpdateRouteComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UpdateRouteComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UpdateRouteComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/routes/update-route/update-route.component.ts b/src/app/routes/update-route/update-route.component.ts new file mode 100644 index 0000000..e284df6 --- /dev/null +++ b/src/app/routes/update-route/update-route.component.ts @@ -0,0 +1,123 @@ +import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core'; +import { + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonIcon, + IonInput, + IonItem, + IonItemOption, + IonItemOptions, + IonItemSliding, + IonLabel, + IonList, + IonModal, + IonReorder, + IonReorderGroup, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar +} from "@ionic/angular/standalone"; +import {Route} from "../model/route"; +import {updateRouteAction} from "../store/routes.actions"; +import {AsyncPipe, NgForOf} from "@angular/common"; +import {FormsModule, ReactiveFormsModule} from "@angular/forms"; +import {CreatePortalComponent} from "../create-portal/create-portal.component"; +import {TypeaheadComponent} from "../../typeahead/typeahead.component"; +import {addIcons} from "ionicons"; +import {pencilOutline, pencilSharp, trashOutline, trashSharp} from "ionicons/icons"; +import {RouteComponent} from "../common/route-component"; +import {UpdatePortalComponent} from "../update-portal/update-portal.component"; +import {CreateDepotComponent} from "../create-depot/create-depot.component"; +import {UpdateDepotComponent} from "../update-depot/update-depot.component"; + +@Component({ + selector: 'app-update-route', + standalone: true, + templateUrl: './update-route.component.html', + imports: [ + AsyncPipe, + FormsModule, + IonButton, + IonButtons, + IonContent, + IonFooter, + IonHeader, + IonInput, + IonItem, + IonModal, + IonSelect, + IonSelectOption, + IonTitle, + IonToolbar, + NgForOf, + ReactiveFormsModule, + CreatePortalComponent, + IonIcon, + IonItemOption, + IonItemOptions, + IonItemSliding, + IonLabel, + IonList, + IonReorder, + IonReorderGroup, + TypeaheadComponent, + UpdatePortalComponent, + CreateDepotComponent, + UpdateDepotComponent + ], + styleUrl: './update-route.component.scss' +}) +export class UpdateRouteComponent extends RouteComponent { + @Output() dismissed: EventEmitter = new EventEmitter(); + + @ViewChild(IonModal) modal: IonModal | undefined; + routeOnOpen: Route = {...this.route}; + + constructor() { + super(); + addIcons({ + trashOutline, + trashSharp, + pencilOutline, + pencilSharp, + }) + } + + private _isOpen: boolean = false; + @Input({required: true}) set isOpen(newValue: boolean) { + if (!this._isOpen && newValue) { + this.routeOnOpen = {...this.route}; + } + this._isOpen = newValue; + } + + get isOpen() { + return this._isOpen; + } + + @Input({required: true}) set updatedRoute(newValue: Route) { + this.route = {...newValue}; + this.routeOnOpen = {...newValue}; + this.updateUnusedStations(); + } + + cancel() { + this.dismissed.emit(true); + this.route = this.routeOnOpen; + this.updateUnusedStations(); + } + + confirm() { + this.route.firstStation = this.route.stations[0]; + this.route.lastStation = this.route.stations[this.route.stations.length - 1]; + this.route.numberOfStations = this.route.stations.length; + this.store.dispatch(updateRouteAction({ + payload: {...this.route} + })) + this.dismissed.emit(true); + } +} diff --git a/src/app/typeahead/item.ts b/src/app/typeahead/item.ts new file mode 100644 index 0000000..efce605 --- /dev/null +++ b/src/app/typeahead/item.ts @@ -0,0 +1,4 @@ +export interface Item { + id: string; + name: string; +} diff --git a/src/app/typeahead/typeahead.component.html b/src/app/typeahead/typeahead.component.html new file mode 100644 index 0000000..d6e87e1 --- /dev/null +++ b/src/app/typeahead/typeahead.component.html @@ -0,0 +1,18 @@ + + + + + + + {{ item.name }} + + @if ((filteredItems$ | async)?.length == 0) { + + No items available + + } + + + + diff --git a/src/app/typeahead/typeahead.component.scss b/src/app/typeahead/typeahead.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/typeahead/typeahead.component.spec.ts b/src/app/typeahead/typeahead.component.spec.ts new file mode 100644 index 0000000..6e1be0f --- /dev/null +++ b/src/app/typeahead/typeahead.component.spec.ts @@ -0,0 +1,23 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +import {TypeaheadComponent} from './typeahead.component'; + +describe('TypeaheadComponent', () => { + let component: TypeaheadComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TypeaheadComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TypeaheadComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/typeahead/typeahead.component.ts b/src/app/typeahead/typeahead.component.ts new file mode 100644 index 0000000..caaf568 --- /dev/null +++ b/src/app/typeahead/typeahead.component.ts @@ -0,0 +1,64 @@ +import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewChild} from '@angular/core'; +import {AsyncPipe, NgForOf} from '@angular/common'; +import {IonContent, IonItem, IonLabel, IonList, IonPopover, IonSearchbar} from "@ionic/angular/standalone"; +import {Item} from "./item"; +import {SearchbarCustomEvent} from "@ionic/angular"; +import {map, Observable} from "rxjs"; + +type PositionAlign = "start" | "center" | "end"; +type PositionSide = "top" | "right" | "bottom" | "left" | "start" | "end"; + +@Component({ + selector: 'app-typeahead', + standalone: true, + imports: [IonContent, IonItem, IonLabel, IonList, IonPopover, IonSearchbar, AsyncPipe, NgForOf], + templateUrl: './typeahead.component.html', + styleUrl: './typeahead.component.scss' +}) +export class TypeaheadComponent implements OnChanges { + @Input() debounce: number = 300; + @Input() event: Event = new Event(''); + @Input() alignment: PositionAlign = 'center'; + @Input() side: PositionSide = 'bottom'; + @Output() dismissed: EventEmitter = new EventEmitter(); + @Output() itemSelected: EventEmitter = new EventEmitter(); + @ViewChild('popover') popover: any; + + @Input({required: true}) isOpen: boolean = false; + @Input({required: true}) items$!: Observable; + @Input() usedItems: T[] = []; + _filteredItems!: Observable; + + ngOnChanges(changes: SimpleChanges) { + const readyForUpdate = this.items$ != null && this.usedItems != null && this.isOpen != null; + if (readyForUpdate + && changes['isOpen']?.currentValue + && !changes['isOpen']?.previousValue) { + this.updateFilteredItems(); + } + } + + get filteredItems$() { + return this._filteredItems; + } + + private updateFilteredItems() { + this._filteredItems = this.items$.pipe( + map(items => items.filter(item => !this.usedItems.some(usedItem => usedItem.id == item.id))) + ); + } + + filterItems(event: SearchbarCustomEvent) { + if (typeof event.detail.value === "string") { + const searchValue = event.detail.value.toLowerCase(); + this._filteredItems = this.items$.pipe( + map(items => items.filter(item => item.name.toLowerCase().includes(searchValue))) + ); + } + } + + selectItem(selectedItem: T) { + this.itemSelected.emit(selectedItem); + this.dismissed.emit(true); + } +}