feat: Add route editing functionality (#20)

fix(formations): Don't overwrite existing formations in local storage
This commit is contained in:
Jim Martens 2023-12-04 18:52:09 +01:00 committed by GitHub
parent 7e44fed534
commit 7a583d81a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 2840 additions and 64 deletions

View File

@ -197,10 +197,15 @@
}, },
"cli": { "cli": {
"schematicCollections": [ "schematicCollections": [
"@schematics/angular",
"@ionic/angular-toolkit" "@ionic/angular-toolkit"
] ]
}, },
"schematics": { "schematics": {
"@schematics/angular:component": {
"standalone": true,
"style": "scss"
},
"@ionic/angular-toolkit:component": { "@ionic/angular-toolkit:component": {
"styleext": "scss" "styleext": "scss"
}, },

142
package-lock.json generated
View File

@ -45,6 +45,7 @@
"@angular/localize": "^17.0.2", "@angular/localize": "^17.0.2",
"@capacitor/cli": "5.5.1", "@capacitor/cli": "5.5.1",
"@ionic/angular-toolkit": "^9.0.0", "@ionic/angular-toolkit": "^9.0.0",
"@schematics/angular": "^17.0.5",
"@types/jasmine": "~4.3.0", "@types/jasmine": "~4.3.0",
"@types/node": "^12.11.1", "@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.10.0",
@ -1317,6 +1318,22 @@
"yarn": ">= 1.13.0" "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": { "node_modules/@angular/common": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-17.0.2.tgz", "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.0.2.tgz",
@ -4562,13 +4579,13 @@
"dev": true "dev": true
}, },
"node_modules/@schematics/angular": { "node_modules/@schematics/angular": {
"version": "17.0.0", "version": "17.0.5",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.0.tgz", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.5.tgz",
"integrity": "sha512-9jKU5x/WzaBsfSkUowK1X74FqtMXa6+A60XgW4ACO8i6fwKfPeS+tIrAieeYOX80/njBh7I5CvcpHmWA2SbcXQ==", "integrity": "sha512-sOc1UG4NiV+7cGwrbWPnyW71O+NgsKaFb2agSrVduRL7o4neMDeqF04ik4Kv1jKA7sZOQfPV+3cn6XI49Mumrw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@angular-devkit/core": "17.0.0", "@angular-devkit/core": "17.0.5",
"@angular-devkit/schematics": "17.0.0", "@angular-devkit/schematics": "17.0.5",
"jsonc-parser": "3.2.0" "jsonc-parser": "3.2.0"
}, },
"engines": { "engines": {
@ -4577,6 +4594,63 @@
"yarn": ">= 1.13.0" "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": { "node_modules/@sigstore/bundle": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz",
@ -18261,6 +18335,19 @@
"semver": "7.5.4", "semver": "7.5.4",
"symbol-observable": "4.0.0", "symbol-observable": "4.0.0",
"yargs": "17.7.2" "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": { "@angular/common": {
@ -20503,14 +20590,49 @@
"dev": true "dev": true
}, },
"@schematics/angular": { "@schematics/angular": {
"version": "17.0.0", "version": "17.0.5",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.0.tgz", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.5.tgz",
"integrity": "sha512-9jKU5x/WzaBsfSkUowK1X74FqtMXa6+A60XgW4ACO8i6fwKfPeS+tIrAieeYOX80/njBh7I5CvcpHmWA2SbcXQ==", "integrity": "sha512-sOc1UG4NiV+7cGwrbWPnyW71O+NgsKaFb2agSrVduRL7o4neMDeqF04ik4Kv1jKA7sZOQfPV+3cn6XI49Mumrw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@angular-devkit/core": "17.0.0", "@angular-devkit/core": "17.0.5",
"@angular-devkit/schematics": "17.0.0", "@angular-devkit/schematics": "17.0.5",
"jsonc-parser": "3.2.0" "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": { "@sigstore/bundle": {

View File

@ -67,6 +67,7 @@
"@angular/localize": "^17.0.2", "@angular/localize": "^17.0.2",
"@capacitor/cli": "5.5.1", "@capacitor/cli": "5.5.1",
"@ionic/angular-toolkit": "^9.0.0", "@ionic/angular-toolkit": "^9.0.0",
"@schematics/angular": "^17.0.5",
"@types/jasmine": "~4.3.0", "@types/jasmine": "~4.3.0",
"@types/node": "^12.11.1", "@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.10.0",

View File

@ -6,6 +6,8 @@ import {provideEffects} from "@ngrx/effects";
import {formationsReducer} from "./formations/store/formations.reducer"; import {formationsReducer} from "./formations/store/formations.reducer";
import {subscriptionReducer} from "./subscription/store/subscription.reducer"; import {subscriptionReducer} from "./subscription/store/subscription.reducer";
import {subscriptionEffects, subscriptionFeature} from "./subscription/store"; 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 = [ export const ROOT_ROUTES: Routes = [
{ {
@ -46,7 +48,12 @@ export const ROOT_ROUTES: Routes = [
{ {
path: 'routes', path: 'routes',
loadComponent: () => import("./routes/routes.component").then(mod => mod.RoutesComponent), 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', path: 'timetables',
@ -68,7 +75,8 @@ export const ROOT_ROUTES: Routes = [
canActivate: [AppAuthGuard], canActivate: [AppAuthGuard],
providers: [ providers: [
provideState(formationsFeature, formationsReducer), provideState(formationsFeature, formationsReducer),
provideEffects(formationsEffects) provideState(routesFeature, routesReducer),
provideEffects(formationsEffects, routesEffects)
], ],
}, },
{ {

View File

@ -9,7 +9,12 @@
<ion-content [fullscreen]="true"> <ion-content [fullscreen]="true">
<ion-list> <ion-list>
<ion-list-header> <ion-list-header>
<ion-label i18n>Routes</ion-label> <ion-label>
<span i18n>Routes</span>
@if (!(hasPersonalPlan$ | async)) {
({{ (routes$ | async)?.length }} / 1)
}
</ion-label>
</ion-list-header> </ion-list-header>
<ion-item> <ion-item>
<ion-label i18n class="bold">Name</ion-label> <ion-label i18n class="bold">Name</ion-label>
@ -18,7 +23,7 @@
<ion-label i18n class="bold">Last Station</ion-label> <ion-label i18n class="bold">Last Station</ion-label>
<ion-label i18n class="bold ion-text-end"># Stations</ion-label> <ion-label i18n class="bold ion-text-end"># Stations</ion-label>
</ion-item> </ion-item>
<ion-item-sliding *ngFor="let route of routes"> <ion-item-sliding *ngFor="let route of routes$ | async">
<ion-item> <ion-item>
<ion-label>{{ route.name }}</ion-label> <ion-label>{{ route.name }}</ion-label>
<ion-label>{{ route.country.name }}</ion-label> <ion-label>{{ route.country.name }}</ion-label>
@ -27,7 +32,11 @@
<ion-label class="ion-text-end">{{ route.numberOfStations }}</ion-label> <ion-label class="ion-text-end">{{ route.numberOfStations }}</ion-label>
</ion-item> </ion-item>
<ion-item-options side="end"> <ion-item-options side="end">
<ion-item-option color="danger" i18n> <ion-item-option (click)="updateRoute(route)" color="secondary" i18n>
<ion-icon slot="start" [ios]="'pencil-outline'" [md]="'pencil-sharp'"></ion-icon>
Update
</ion-item-option>
<ion-item-option (click)="deleteRoute(route)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon> <ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete Delete
</ion-item-option> </ion-item-option>
@ -99,14 +108,19 @@
<ion-fab-button i18n-aria-label aria-label="Add timetable" [show]="true"> <ion-fab-button i18n-aria-label aria-label="Add timetable" [show]="true">
<ion-icon [ios]="'time-outline'" [md]="'time-sharp'"></ion-icon> <ion-icon [ios]="'time-outline'" [md]="'time-sharp'"></ion-icon>
</ion-fab-button> </ion-fab-button>
<ion-fab-button i18n-aria-label aria-label="Add route" [show]="true"> @if ((routes$ | async)?.length == 0 || (hasPersonalPlan$ | async)) {
<ion-icon [ios]="'map-outline'" [md]="'map-sharp'"></ion-icon> <ion-fab-button i18n-aria-label aria-label="Add route" [show]="true" (click)="addRoute()">
</ion-fab-button> <ion-icon [ios]="'map-outline'" [md]="'map-sharp'"></ion-icon>
</ion-fab-button>
}
</ion-fab-list> </ion-fab-list>
</ion-fab> </ion-fab>
<app-create-formation [isOpen]="isCreateFormationModalOpen" <app-create-formation [isOpen]="isCreateFormationModalOpen"
(dismissed)="isCreateFormationModalOpen = false"></app-create-formation> (dismissed)="isCreateFormationModalOpen = false"></app-create-formation>
<app-update-formation [isOpen]="isUpdateFormationModalOpen" [updatedFormation]="updatedFormation!" <app-update-formation [isOpen]="isUpdateFormationModalOpen" [updatedFormation]="updatedFormation!"
(dismissed)="isUpdateFormationModalOpen = false"></app-update-formation> (dismissed)="isUpdateFormationModalOpen = false"></app-update-formation>
<app-create-route [isOpen]="isCreateRouteModalOpen" (dismissed)="isCreateRouteModalOpen = false"></app-create-route>
<app-update-route [isOpen]="isUpdateRouteModalOpen" [updatedRoute]="updatedRoute"
(dismissed)="isUpdateRouteModalOpen = false"></app-update-route>
</ion-content> </ion-content>
<ion-footer id="footer"></ion-footer> <ion-footer id="footer"></ion-footer>

View File

@ -40,7 +40,7 @@ import {
trashOutline, trashOutline,
trashSharp trashSharp
} from "ionicons/icons"; } from "ionicons/icons";
import {Route} from "../routes/model/route"; import {DEFAULT_ROUTE, Route} from "../routes/model/route";
import {Timetable} from "../timetables/model/timetable"; import {Timetable} from "../timetables/model/timetable";
import {FormationsStoreService} from "../formations/service/formations-store.service"; import {FormationsStoreService} from "../formations/service/formations-store.service";
import {DEFAULT_FORMATION, Formation} from "../formations/model/formation"; 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 {addMessageAction} from "../messages/store/messages.actions";
import {Message} from "../messages/model/message"; import {Message} from "../messages/model/message";
import {MessagesState} from "../messages/store/messages.reducer"; 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({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
@ -85,19 +91,12 @@ import {MessagesState} from "../messages/store/messages.reducer";
IonFabList, IonFabList,
IonFooter, IonFooter,
CreateFormationComponent, CreateFormationComponent,
UpdateFormationComponent UpdateFormationComponent,
CreateRouteComponent,
UpdateRouteComponent
] ]
}) })
export class DashboardComponent implements OnDestroy { 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[] = []; timetables: Timetable[] = [];
isCreateFormationModalOpen = false; isCreateFormationModalOpen = false;
@ -106,6 +105,19 @@ export class DashboardComponent implements OnDestroy {
private readonly formationsStoreService: FormationsStoreService = inject(FormationsStoreService); private readonly formationsStoreService: FormationsStoreService = inject(FormationsStoreService);
formations$ = this.formationsStoreService.getFormations$(); 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<string, Message> = { private messages: Record<string, Message> = {
success: { success: {
text: $localize`You have successfully subscribed. The subscription can be managed from the account settings.`, 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; private subscription: Subscription;
constructor(private readonly formationsStore: Store<FormationsState>, constructor(private readonly formationsStore: Store<FormationsState>,
private readonly routesStore: Store<RoutesState>,
private readonly messagesStore: Store<MessagesState>, private readonly messagesStore: Store<MessagesState>,
private readonly activatedRoute: ActivatedRoute, private readonly activatedRoute: ActivatedRoute,
private readonly router: Router) { private readonly router: Router) {
@ -160,6 +173,19 @@ export class DashboardComponent implements OnDestroy {
this.formationsStore.dispatch(deleteFormationAction({payload: formation})); 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() { private triggerFeedbackMessage() {
const state = this.activatedRoute.snapshot.queryParamMap.get('state') || ''; const state = this.activatedRoute.snapshot.queryParamMap.get('state') || '';
if (state in this.messages) { if (state in this.messages) {

View File

@ -61,7 +61,7 @@ import {FormationsState} from "./store/formations.reducer";
export class FormationsComponent { export class FormationsComponent {
isCreateModalOpen = false; isCreateModalOpen = false;
isUpdateModalOpen = false; isUpdateModalOpen = false;
updatedFormation: Formation = DEFAULT_FORMATION; updatedFormation: Formation = {...DEFAULT_FORMATION};
private readonly storeService: FormationsStoreService = inject(FormationsStoreService); private readonly storeService: FormationsStoreService = inject(FormationsStoreService);
formations$ = this.storeService.getFormations$(); formations$ = this.storeService.getFormations$();
@ -82,8 +82,8 @@ export class FormationsComponent {
} }
updateFormation(formation: Formation) { updateFormation(formation: Formation) {
this.isUpdateModalOpen = true;
this.updatedFormation = formation; this.updatedFormation = formation;
this.isUpdateModalOpen = true;
} }
deleteFormation(formation: Formation) { deleteFormation(formation: Formation) {

View File

@ -1,4 +1,6 @@
export interface Formation { import {Item} from "../../typeahead/item";
export interface Formation extends Item {
id: string; id: string;
name: string; name: string;
trainSimWorldFormation?: Formation; trainSimWorldFormation?: Formation;

View File

@ -25,6 +25,7 @@ export class FormationsService {
fetchFormations(): Observable<Formation[]> { fetchFormations(): Observable<Formation[]> {
if (environment.mockNetwork) { if (environment.mockNetwork) {
this.formations = JSON.parse(localStorage.getItem("formations") || '[]'); this.formations = JSON.parse(localStorage.getItem("formations") || '[]');
this.formations.forEach(formation => this.knownFormations.set(formation.id, formation));
return of(this.formations); return of(this.formations);
} }

View File

@ -48,10 +48,10 @@ import {FormationsState} from "../store/formations.reducer";
export class UpdateFormationComponent { export class UpdateFormationComponent {
@ViewChild(IonModal) modal: IonModal | undefined; @ViewChild(IonModal) modal: IonModal | undefined;
@Input({required: true}) isOpen: boolean = false;
@Output() dismissed: EventEmitter<boolean> = new EventEmitter<boolean>(); @Output() dismissed: EventEmitter<boolean> = new EventEmitter<boolean>();
formation: Formation = {...DEFAULT_FORMATION}; formation: Formation = {...DEFAULT_FORMATION};
formationOnOpen: Formation = {...this.formation};
private readonly store: Store<FormationsState> = inject(Store<FormationsState>); private readonly store: Store<FormationsState> = inject(Store<FormationsState>);
private readonly storeService: FormationsStoreService = inject(FormationsStoreService); private readonly storeService: FormationsStoreService = inject(FormationsStoreService);
@ -60,13 +60,26 @@ export class UpdateFormationComponent {
constructor() { 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) { @Input({required: true}) set updatedFormation(newValue: Formation) {
this.formation = {...newValue}; this.formation = {...newValue};
this.formationOnOpen = {...newValue};
} }
cancel() { cancel() {
this.dismissed.emit(true); this.dismissed.emit(true);
this.formation = {...DEFAULT_FORMATION}; this.formation = {...this.formationOnOpen};
} }
confirm() { confirm() {
@ -74,7 +87,6 @@ export class UpdateFormationComponent {
payload: {...this.formation} payload: {...this.formation}
})) }))
this.dismissed.emit(true); this.dismissed.emit(true);
this.formation = {...DEFAULT_FORMATION};
} }
compareWith(formation1: Formation, formation2: Formation) { compareWith(formation1: Formation, formation2: Formation) {

View File

@ -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<Formation[]>;
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)
)),
);
}
}

View File

@ -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<Formation[]>;
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)
)),
);
}
}

View File

@ -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<RoutesState> = inject(Store<RoutesState>);
readonly countries$ = this.store.select(allCountries());
private readonly storeService: RoutesStoreService = inject(RoutesStoreService);
readonly stations$ = this.storeService.getStations$();
unusedStations$!: Observable<Station[]>;
compareWithCountry(country1: Country, country2: Country) {
return country1 && country2 ? country1.code === country2.code : country1 === country2;
}
trackBy<T extends Item>(_: 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)
)),
);
}
}

View File

@ -0,0 +1,139 @@
<ion-modal aria-labelledby="create-depot-title" [isOpen]="isOpen"
(keyup.escape)="cancel()"
backdrop-dismiss="false">
<ng-template>
<ion-header>
<ion-toolbar>
<ion-title id="create-depot-title" i18n>Create Depot</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form id="createDepotForm" #createDepot="ngForm" (ngSubmit)="confirm()">
<ion-input
i18n-label
label="ID"
labelPlacement="stacked"
type="text"
[required]="true"
name="id"
i18n-placeholder
placeholder="unique-id"
i18n-helper-text
helper-text="You cannot change this value later on. Uniquely identifies this depot."
pattern="^[\w\-]+$"
i18n-title
title="Only letters and dashes (-) are allowed."
[(ngModel)]="depot.id"
></ion-input>
<ion-input
i18n-label
label="Name"
labelPlacement="stacked"
type="text"
name="name"
[required]="true"
i18n-placeholder
placeholder="Name of the depot"
[(ngModel)]="depot.name"
></ion-input>
<ion-select
i18n-label
label="Nearest Station"
labelPlacement="stacked"
i18n-placeholder
placeholder="Closest station of the route"
name="nearestStation"
interface="popover"
[required]="true"
[compareWith]="compareWithStation"
[(ngModel)]="depot.nearestStation"
>
<ion-select-option *ngFor="let station of stations"
[value]="station">
{{ station.name }}
</ion-select-option>
</ion-select>
<h6 i18n>Tracks</h6>
<ion-list>
<ion-item>
<ion-label i18n class="bold">Name</ion-label>
<ion-label i18n class="bold">Capacity</ion-label>
</ion-item>
<ion-item-sliding *ngFor="let track of depot.tracks; index as i; trackBy: trackByTrack">
<ion-item>
<ion-input i18n-aria-label aria-label="Name" type="text" [value]="track.name"
(ionChange)="changeName(i, $event)"></ion-input>
<ion-input i18n-aria-label aria-label="Capacity" type="number" [value]="track.capacity"
min="0"
(ionChange)="changeCapacity(i, $event)"></ion-input>
</ion-item>
<ion-item-options>
<ion-item-option (click)="deleteTrack(track)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-button (click)="addTrack()">
<ion-label i18n>Add track</ion-label>
</ion-button>
<h6 i18n>Travel Durations to nearest station</h6>
<ion-list>
<ion-item>
<ion-label i18n class="bold">Formation</ion-label>
<ion-label i18n class="bold">Time (s)</ion-label>
</ion-item>
<ion-item-sliding
*ngFor="let travelDuration of depot.travelDurations; index as i; trackBy: trackByTravelDuration">
<ion-item>
<ion-select
interface="popover"
[value]="travelDuration.formation"
(ionChange)="onSelectFormation($event, i)"
[compareWith]="compareWithFormation"
>
<ion-select-option [value]="travelDuration.formation">
{{ travelDuration.formation.name }}
</ion-select-option>
<ion-select-option *ngFor="let formation of unusedFormations$ | async" [value]="formation">
{{ formation.name }}
</ion-select-option>
</ion-select>
<ion-input i18n-aria-label aria-label="Time in seconds" type="number" [value]="travelDuration.time"
min="0"
(ionChange)="changeTime(i, $event)"></ion-input>
</ion-item>
<ion-item-options>
<ion-item-option (click)="deleteTravelDuration(travelDuration)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-button (click)="addTravelDuration($event)" [disabled]="(unusedFormations$ | async)?.length == 0">
<ion-label i18n>Add travel duration</ion-label>
</ion-button>
</form>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="cancel()" i18n>Cancel</ion-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button form="createDepotForm" type="submit" button-type="submit" [strong]="true" i18n
[disabled]="!createDepot.form.valid">Confirm
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
</ng-template>
</ion-modal>
<app-typeahead [isOpen]="isFormationPopoverOpen" (dismissed)="isFormationPopoverOpen = false"
(itemSelected)="selectFormation($event)" [items$]="formations$" [usedItems]="usedFormations"
[event]="clickEvent"></app-typeahead>

View File

@ -0,0 +1,3 @@
.bold {
font-weight: bold;
}

View File

@ -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<CreateDepotComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CreateDepotComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CreateDepotComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<boolean> = new EventEmitter<boolean>();
@Output() createdDepot: EventEmitter<Depot> = new EventEmitter<Depot>();
@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();
}
}

View File

@ -0,0 +1,111 @@
<ion-modal aria-labelledby="create-portal-title" [isOpen]="isOpen"
(keyup.escape)="cancel()"
backdrop-dismiss="false">
<ng-template>
<ion-header>
<ion-toolbar>
<ion-title id="create-portal-title" i18n>Create Portal</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form id="createPortalForm" #createPortal="ngForm" (ngSubmit)="confirm()">
<ion-input
i18n-label
label="ID"
labelPlacement="stacked"
type="text"
[required]="true"
name="id"
i18n-placeholder
placeholder="unique-id"
i18n-helper-text
helper-text="You cannot change this value later on. Uniquely identifies this portal."
pattern="^[\w\-]+$"
i18n-title
title="Only letters and dashes (-) are allowed."
[(ngModel)]="portal.id"
></ion-input>
<ion-input
i18n-label
label="Name"
labelPlacement="stacked"
type="text"
name="name"
[required]="true"
i18n-placeholder
placeholder="Name of the portal"
[(ngModel)]="portal.name"
></ion-input>
<ion-select
i18n-label
label="Nearest Station"
labelPlacement="stacked"
i18n-placeholder
placeholder="Closest station of the route"
name="nearestStation"
interface="popover"
[required]="true"
[compareWith]="compareWithStation"
[(ngModel)]="portal.nearestStation"
>
<ion-select-option *ngFor="let station of stations"
[value]="station">
{{ station.name }}
</ion-select-option>
</ion-select>
<h6 i18n>Travel Durations to nearest station</h6>
<ion-list>
<ion-item>
<ion-label i18n class="bold">Formation</ion-label>
<ion-label i18n class="bold">Time (s)</ion-label>
</ion-item>
<ion-item-sliding *ngFor="let travelDuration of portal.travelDurations; index as i; trackBy: trackBy">
<ion-item>
<ion-select
interface="popover"
[value]="travelDuration.formation"
(ionChange)="onSelectFormation($event, i)"
[compareWith]="compareWithFormation"
>
<ion-select-option [value]="travelDuration.formation">
{{ travelDuration.formation.name }}
</ion-select-option>
<ion-select-option *ngFor="let formation of unusedFormations$ | async" [value]="formation">
{{ formation.name }}
</ion-select-option>
</ion-select>
<ion-input i18n-aria-label aria-label="Time in seconds" type="number"
(ionChange)="changeTime(i, $event)"></ion-input>
</ion-item>
<ion-item-options>
<ion-item-option (click)="deleteTravelDuration(travelDuration)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-button (click)="addTravelDuration($event)" [disabled]="(unusedFormations$ | async)?.length == 0">
<ion-label i18n>Add travel duration</ion-label>
</ion-button>
</form>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="cancel()" i18n>Cancel</ion-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button form="createPortalForm" type="submit" button-type="submit" [strong]="true" i18n
[disabled]="!createPortal.form.valid">Confirm
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
</ng-template>
</ion-modal>
<app-typeahead [isOpen]="isFormationPopoverOpen" (dismissed)="isFormationPopoverOpen = false"
(itemSelected)="selectFormation($event)" [items$]="formations$" [usedItems]="usedFormations"
[event]="clickEvent"></app-typeahead>

View File

@ -0,0 +1,3 @@
.bold {
font-weight: bold;
}

View File

@ -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<CreatePortalComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CreatePortalComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CreatePortalComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<boolean> = new EventEmitter<boolean>();
@Output() createdPortal: EventEmitter<Portal> = new EventEmitter<Portal>();
@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();
}
}

View File

@ -0,0 +1,148 @@
<ion-modal aria-labelledby="create-route-title" [isOpen]="isOpen"
(keyup.escape)="cancel()"
backdrop-dismiss="false">
<ng-template>
<ion-header>
<ion-toolbar>
<ion-title id="create-route-title" i18n>Create Route</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form id="createRouteForm" #createRoute="ngForm" (ngSubmit)="confirm()">
<ion-input
i18n-label
label="ID"
labelPlacement="stacked"
type="text"
[required]="true"
name="id"
i18n-placeholder
placeholder="unique-id"
i18n-helper-text
helper-text="You cannot change this value later on. Uniquely identifies this route."
pattern="^[\w\-]+$"
i18n-title
title="Only letters and dashes (-) are allowed."
[(ngModel)]="route.id"
></ion-input>
<ion-input
i18n-label
label="Name"
labelPlacement="stacked"
type="text"
name="name"
[required]="true"
i18n-placeholder
placeholder="Name of the route"
[(ngModel)]="route.name"
></ion-input>
<ion-select
i18n-label
label="Country"
labelPlacement="stacked"
i18n-placeholder
placeholder="Country of the route"
name="country"
interface="popover"
[compareWith]="compareWithCountry"
[(ngModel)]="route.country"
>
<ion-select-option *ngFor="let country of countries$ | async"
[value]="country">
{{ country.name }}
</ion-select-option>
</ion-select>
<h6 i18n>Stations</h6>
<ion-list>
<ion-reorder-group [disabled]="false" (ionItemReorder)="handleReorderStations($event)">
<ion-item-sliding *ngFor="let station of route.stations; index as i; trackBy: trackBy">
<ion-item [button]="true" (click)="openPopoverStation($event, i)">
<ion-reorder slot="start"></ion-reorder>
<ion-label class="no-pointer-events">{{ station.name }}</ion-label>
</ion-item>
<ion-item-options>
<ion-item-option (click)="deleteStation(station)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-reorder-group>
</ion-list>
<ion-button (click)="openPopoverStation($event)" [disabled]="(unusedStations$ | async)?.length == 0">
<ion-label i18n class="no-pointer-events">Add station</ion-label>
</ion-button>
<h6 i18n>Portals</h6>
<ion-list>
<ion-item-sliding *ngFor="let portal of route.portals; index as i; trackBy: trackBy">
<ion-item>
<ion-label>{{ portal.name }}</ion-label>
</ion-item>
<ion-item-options>
<ion-item-option (click)="updatePortal(portal, i)" color="secondary" i18n>
<ion-icon slot="start" [ios]="'pencil-outline'" [md]="'pencil-sharp'"></ion-icon>
Update
</ion-item-option>
<ion-item-option (click)="deletePortal(portal)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-button (click)="addPortal()">
<ion-label i18n>Add portal</ion-label>
</ion-button>
<h6 i18n>Depots</h6>
<ion-list>
<ion-item-sliding *ngFor="let depot of route.depots; index as i; trackBy: trackBy">
<ion-item>
<ion-label>{{ depot.name }}</ion-label>
</ion-item>
<ion-item-options>
<ion-item-option (click)="updateDepot(depot, i)" color="secondary" i18n>
<ion-icon slot="start" [ios]="'pencil-outline'" [md]="'pencil-sharp'"></ion-icon>
Update
</ion-item-option>
<ion-item-option (click)="deleteDepot(depot)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-button (click)="addDepot()">
<ion-label i18n>Add depot</ion-label>
</ion-button>
</form>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="cancel()" i18n>Cancel</ion-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button form="createRouteForm" type="submit" button-type="submit" [strong]="true" i18n
[disabled]="!createRoute.form.valid || route.stations.length == 0">Confirm
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
</ng-template>
</ion-modal>
<app-create-portal [isOpen]="isCreatePortalModalOpen" (dismissed)="isCreatePortalModalOpen = false"
[stations]="route.stations" (createdPortal)="insertPortal($event)"></app-create-portal>
<app-update-portal [isOpen]="isUpdatePortalModalOpen" (dismissed)="isUpdatePortalModalOpen = false"
[stations]="route.stations" [(updatedPortal)]="updatedPortal"></app-update-portal>
<app-create-depot [isOpen]="isCreateDepotModalOpen" (dismissed)="isCreateDepotModalOpen = false"
[stations]="route.stations" (createdDepot)="insertDepot($event)"></app-create-depot>
<app-update-depot [isOpen]="isUpdateDepotModalOpen" (dismissed)="isUpdateDepotModalOpen = false"
[stations]="route.stations" [(updatedDepot)]="updatedDepot"></app-update-depot>
<app-typeahead [isOpen]="isStationPopoverOpen" (dismissed)="isStationPopoverOpen = false"
(itemSelected)="selectStation($event)" [items$]="stations$" [usedItems]="route.stations"
[event]="clickEvent"></app-typeahead>

View File

@ -0,0 +1,3 @@
.no-pointer-events {
pointer-events: none;
}

View File

@ -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<CreateRouteComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CreateRouteComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CreateRouteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<boolean> = new EventEmitter<boolean>();
@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();
}
}

View File

@ -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: []
};

View File

@ -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: []
};

View File

@ -1,14 +1,31 @@
import {Station} from "./station"; import {DEFAULT_STATION, Station} from "./station";
import {Country} from "./country"; import {Country} from "./country";
import {Depot} from "./depot";
import {Portal} from "./portal";
export interface Route { export interface Route {
id: string;
name: string; name: string;
country: Country; country: Country;
stations: Station[];
firstStation: Station; firstStation: Station;
lastStation: Station; lastStation: Station;
numberOfStations: number; numberOfStations: number;
depots: Depot[];
portals: Portal[];
} }
export interface EditedRoute extends Route { export const DEFAULT_ROUTE: Route = {
stations: Station[]; id: "",
name: "",
country: {
code: 'de',
name: 'Germany'
},
firstStation: DEFAULT_STATION,
lastStation: DEFAULT_STATION,
stations: [],
numberOfStations: 0,
depots: [],
portals: []
} }

View File

@ -1,3 +1,11 @@
export interface Station { import {Item} from "../../typeahead/item";
export interface Station extends Item {
id: string;
name: string; name: string;
} }
export const DEFAULT_STATION: Station = {
id: '',
name: ''
};

View File

@ -0,0 +1,6 @@
import {Formation} from "../../formations/model/formation";
export interface TravelDuration {
formation: Formation;
time: number;
}

View File

@ -3,7 +3,12 @@
<ion-buttons slot="start"> <ion-buttons slot="start">
<ion-menu-button></ion-menu-button> <ion-menu-button></ion-menu-button>
</ion-buttons> </ion-buttons>
<ion-title i18n="page title">Routes</ion-title> <ion-title>
<span i18n="page title">Routes</span>
@if (!(hasPersonalPlan$ | async)) {
({{ (routes$ | async)?.length }} / 1)
}
</ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content [fullscreen]="true"> <ion-content [fullscreen]="true">
@ -15,8 +20,8 @@
<ion-label i18n class="bold">Last Station</ion-label> <ion-label i18n class="bold">Last Station</ion-label>
<ion-label i18n class="bold"># Stations</ion-label> <ion-label i18n class="bold"># Stations</ion-label>
</ion-item> </ion-item>
<ion-item-sliding> <ion-item-sliding *ngFor="let route of routes$ | async">
<ion-item *ngFor="let route of routes"> <ion-item>
<ion-label>{{ route.name }}</ion-label> <ion-label>{{ route.name }}</ion-label>
<ion-label>{{ route.country.name }}</ion-label> <ion-label>{{ route.country.name }}</ion-label>
<ion-label>{{ route.firstStation.name }}</ion-label> <ion-label>{{ route.firstStation.name }}</ion-label>
@ -24,17 +29,26 @@
<ion-label>{{ route.numberOfStations }}</ion-label> <ion-label>{{ route.numberOfStations }}</ion-label>
</ion-item> </ion-item>
<ion-item-options side="end"> <ion-item-options side="end">
<ion-item-option color="danger" i18n> <ion-item-option (click)="updateRoute(route)" color="secondary" i18n>
<ion-icon slot="start" [ios]="'pencil-outline'" [md]="'pencil-sharp'"></ion-icon>
Update
</ion-item-option>
<ion-item-option (click)="deleteRoute(route)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon> <ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete Delete
</ion-item-option> </ion-item-option>
</ion-item-options> </ion-item-options>
</ion-item-sliding> </ion-item-sliding>
</ion-list> </ion-list>
<ion-fab slot="fixed" horizontal="end" vertical="bottom"> @if ((routes$ | async)?.length == 0 || (hasPersonalPlan$ | async)) {
<ion-fab-button i18n-aria-label aria-label="Add route"> <ion-fab slot="fixed" horizontal="end" vertical="bottom">
<ion-icon [ios]="'add-outline'" [md]="'add-sharp'"></ion-icon> <ion-fab-button i18n-aria-label aria-label="Add route" (click)="addRoute()">
</ion-fab-button> <ion-icon [ios]="'add-outline'" [md]="'add-sharp'"></ion-icon>
</ion-fab> </ion-fab-button>
</ion-fab>
}
<app-create-route [isOpen]="isCreateModalOpen" (dismissed)="isCreateModalOpen = false"></app-create-route>
<app-update-route [isOpen]="isUpdateModalOpen" [updatedRoute]="updatedRoute!"
(dismissed)="isUpdateModalOpen = false"></app-update-route>
</ion-content> </ion-content>
<ion-footer></ion-footer> <ion-footer id="footer"></ion-footer>

View File

@ -1,8 +1,8 @@
import {Component} from '@angular/core'; import {Component, inject} from '@angular/core';
import {Route} from "./model/route"; import {DEFAULT_ROUTE, Route} from "./model/route";
import {addIcons} from "ionicons"; import {addIcons} from "ionicons";
import {addOutline, addSharp, trashOutline, trashSharp} from "ionicons/icons"; import {addOutline, addSharp, pencilOutline, pencilSharp, trashOutline, trashSharp} from "ionicons/icons";
import {NgForOf} from "@angular/common"; import {AsyncPipe, NgForOf} from "@angular/common";
import { import {
IonButtons, IonButtons,
IonContent, IonContent,
@ -22,6 +22,16 @@ import {
IonTitle, IonTitle,
IonToolbar IonToolbar
} from "@ionic/angular/standalone"; } 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({ @Component({
selector: 'app-routes', selector: 'app-routes',
@ -46,27 +56,50 @@ import {
IonIcon, IonIcon,
IonFab, IonFab,
IonFabButton, IonFabButton,
IonFooter IonFooter,
AsyncPipe,
CreateFormationComponent,
UpdateFormationComponent,
CreateRouteComponent,
UpdateRouteComponent
] ]
}) })
export class RoutesComponent { export class RoutesComponent {
isCreateModalOpen = false;
isUpdateModalOpen = false;
updatedRoute: Route = {...DEFAULT_ROUTE};
routes: Route[] = [ private readonly storeService: RoutesStoreService = inject(RoutesStoreService);
{ routes$ = this.storeService.getRoutes$();
name: 'Köln-Aachen',
country: {name: $localize`Germany`, code: 'de'},
firstStation: {name: 'Köln Hbf'},
lastStation: {name: 'Aachen Hbf'},
numberOfStations: 30
}
];
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<RoutesState>) {
addIcons({ addIcons({
addOutline, addOutline,
addSharp, addSharp,
trashOutline, trashOutline,
trashSharp, 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}));
}
} }

View File

@ -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();
});
});

View File

@ -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<string, Route> = new Map<string, Route>();
private routes: Route[] = [];
constructor(private readonly http: HttpClient,
private readonly errorService: ErrorService) {
}
fetchRoutes(): Observable<Route[]> {
if (environment.mockNetwork) {
this.routes = JSON.parse(localStorage.getItem("routes") || '[]');
return of(this.routes);
}
return this.http.get<Route[]>(this.routesURL, this.httpOptions)
.pipe(
catchError(this.errorService.handleError<Route[]>('Routes',
'fetchRoutes', []))
);
}
storeRoute(route: Route): Observable<Route> {
if (environment.mockNetwork) {
this.knownRoutes.set(route.id, route);
this.storeRoutesInLocalStorage();
return of(route);
}
return this.http.put<Route>(
this.routesURL + '/' + encodeURIComponent(route.id),
route,
this.httpOptions
).pipe(
catchError(this.errorService.handleError<Route>('Routes',
'storeRoute', route))
)
}
deleteRoute(route: Route): Observable<ArrayBuffer> {
if (environment.mockNetwork) {
this.knownRoutes.delete(route.id);
this.storeRoutesInLocalStorage();
return of(new ArrayBuffer(0));
}
return this.http.delete<ArrayBuffer>(
this.routesURL + '/' + encodeURIComponent(route.id),
this.httpOptions
).pipe(
catchError(this.errorService.handleError<ArrayBuffer>('Route',
'deleteRoute', new ArrayBuffer(0)))
)
}
private storeRoutesInLocalStorage() {
this.routes = Array.from(this.knownRoutes.values());
localStorage.setItem("routes", JSON.stringify(this.routes));
}
}

View File

@ -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();
});
});

View File

@ -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<RoutesState>) {
}
getRoutes$() {
return using(
() => this.loadRoutes$().subscribe(),
() => this.store.select(allRoutes())
)
}
private loadRoutes$(): Observable<Route[]> {
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<Station[]> {
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()
);
}
}

View File

@ -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();
});
});

View File

@ -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<string, Station> = new Map<string, Station>();
private stations: Station[] = [];
constructor(private readonly http: HttpClient,
private readonly errorService: ErrorService) {
}
fetchStations(): Observable<Station[]> {
if (environment.mockNetwork) {
this.stations = JSON.parse(localStorage.getItem("stations") || '[]');
return of(this.stations);
}
return this.http.get<Route[]>(this.stationsURL, this.httpOptions)
.pipe(
catchError(this.errorService.handleError<Route[]>('Routes',
'fetchStations', []))
);
}
}

View File

@ -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<string, FunctionalEffect> = {
storeRoute: storeRoute,
deleteRoute: deleteRoute
}
export const getRoutesFeatureState = createFeatureSelector<RoutesState>(
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
);

View File

@ -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
);

View File

@ -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});

View File

@ -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
})
}))
);

View File

@ -0,0 +1,133 @@
<ion-modal aria-labelledby="update-depot-title" [isOpen]="isOpen"
(keyup.escape)="cancel()"
backdrop-dismiss="false">
<ng-template>
<ion-header>
<ion-toolbar>
<ion-title id="update-depot-title" i18n>Update Depot</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form id="updateDepotForm" #updateDepot="ngForm" (ngSubmit)="confirm()">
<ion-input
i18n-label
label="ID"
labelPlacement="stacked"
type="text"
[required]="true"
name="id"
[readonly]="true"
[(ngModel)]="depot.id"
></ion-input>
<ion-input
i18n-label
label="Name"
labelPlacement="stacked"
type="text"
name="name"
[required]="true"
i18n-placeholder
placeholder="Name of the depot"
[(ngModel)]="depot.name"
></ion-input>
<ion-select
i18n-label
label="Nearest Station"
labelPlacement="stacked"
i18n-placeholder
placeholder="Closest station of the route"
name="nearestStation"
interface="popover"
[required]="true"
[compareWith]="compareWithStation"
[(ngModel)]="depot.nearestStation"
>
<ion-select-option *ngFor="let station of stations"
[value]="station">
{{ station.name }}
</ion-select-option>
</ion-select>
<h6 i18n>Tracks</h6>
<ion-list>
<ion-item>
<ion-label i18n class="bold">Name</ion-label>
<ion-label i18n class="bold">Capacity</ion-label>
</ion-item>
<ion-item-sliding *ngFor="let track of depot.tracks; index as i; trackBy: trackByTrack">
<ion-item>
<ion-input i18n-aria-label aria-label="Name" type="text" [value]="track.name"
(ionChange)="changeName(i, $event)"></ion-input>
<ion-input i18n-aria-label aria-label="Capacity" type="number" [value]="track.capacity"
(ionChange)="changeCapacity(i, $event)"></ion-input>
</ion-item>
<ion-item-options>
<ion-item-option (click)="deleteTrack(track)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-button (click)="addTrack()">
<ion-label i18n>Add track</ion-label>
</ion-button>
<h6 i18n>Travel Durations to nearest station</h6>
<ion-list>
<ion-item>
<ion-label i18n class="bold">Formation</ion-label>
<ion-label i18n class="bold">Time (s)</ion-label>
</ion-item>
<ion-item-sliding
*ngFor="let travelDuration of depot.travelDurations; index as i; trackBy: trackByTravelDuration">
<ion-item>
<ion-select
i18n-aria-label
aria-label="Formation"
interface="popover"
[value]="travelDuration.formation"
(ionChange)="onSelectFormation($event, i)"
[compareWith]="compareWithFormation"
>
<ion-select-option [value]="travelDuration.formation">
{{ travelDuration.formation.name }}
</ion-select-option>
<ion-select-option *ngFor="let formation of unusedFormations$ | async" [value]="formation">
{{ formation.name }}
</ion-select-option>
</ion-select>
<ion-input i18n-aria-label aria-label="Time in seconds" type="number" [value]="travelDuration.time"
(ionChange)="changeTime(i, $event)"></ion-input>
</ion-item>
<ion-item-options>
<ion-item-option (click)="deleteTravelDuration(travelDuration)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-button (click)="addTravelDuration($event)" [disabled]="(unusedFormations$ | async)?.length == 0">
<ion-label i18n>Add travel duration</ion-label>
</ion-button>
</form>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="cancel()" i18n>Cancel</ion-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button form="updateDepotForm" type="submit" button-type="submit" [strong]="true" i18n
[disabled]="!updateDepot.form.valid">Confirm
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
</ng-template>
</ion-modal>
<app-typeahead [isOpen]="isFormationPopoverOpen" (dismissed)="isFormationPopoverOpen = false"
(itemSelected)="selectFormation($event)" [items$]="formations$" [usedItems]="usedFormations"
[event]="clickEvent"></app-typeahead>

View File

@ -0,0 +1,3 @@
.bold {
font-weight: bold;
}

View File

@ -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<UpdateDepotComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UpdateDepotComponent]
})
.compileComponents();
fixture = TestBed.createComponent(UpdateDepotComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<boolean> = new EventEmitter<boolean>();
@Output() updatedDepotChange: EventEmitter<Depot> = 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);
}
}

View File

@ -0,0 +1,107 @@
<ion-modal aria-labelledby="update-portal-title" [isOpen]="isOpen"
(keyup.escape)="cancel()"
backdrop-dismiss="false">
<ng-template>
<ion-header>
<ion-toolbar>
<ion-title id="update-portal-title" i18n>Update Portal</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form id="updatePortalForm" #updatePortal="ngForm" (ngSubmit)="confirm()">
<ion-input
i18n-label
label="ID"
labelPlacement="stacked"
type="text"
[required]="true"
name="id"
[readonly]="true"
[(ngModel)]="portal.id"
></ion-input>
<ion-input
i18n-label
label="Name"
labelPlacement="stacked"
type="text"
name="name"
[required]="true"
i18n-placeholder
placeholder="Name of the portal"
[(ngModel)]="portal.name"
></ion-input>
<ion-select
i18n-label
label="Nearest Station"
labelPlacement="stacked"
i18n-placeholder
placeholder="Closest station of the route"
name="nearestStation"
interface="popover"
[required]="true"
[compareWith]="compareWithStation"
[(ngModel)]="portal.nearestStation"
>
<ion-select-option *ngFor="let station of stations"
[value]="station">
{{ station.name }}
</ion-select-option>
</ion-select>
<h6 i18n>Travel Durations to nearest station</h6>
<ion-list>
<ion-item>
<ion-label i18n class="bold">Formation</ion-label>
<ion-label i18n class="bold">Time (s)</ion-label>
</ion-item>
<ion-item-sliding *ngFor="let travelDuration of portal.travelDurations; index as i; trackBy: trackBy">
<ion-item>
<ion-select
i18n-aria-label
aria-label="Formation"
interface="popover"
[value]="travelDuration.formation"
(ionChange)="onSelectFormation($event, i)"
[compareWith]="compareWithFormation"
>
<ion-select-option [value]="travelDuration.formation">
{{ travelDuration.formation.name }}
</ion-select-option>
<ion-select-option *ngFor="let formation of unusedFormations$ | async" [value]="formation">
{{ formation.name }}
</ion-select-option>
</ion-select>
<ion-input i18n-aria-label aria-label="Time in seconds" type="number" [value]="travelDuration.time"
(ionChange)="changeTime(i, $event)"></ion-input>
</ion-item>
<ion-item-options>
<ion-item-option (click)="deleteTravelDuration(travelDuration)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-button (click)="addTravelDuration($event)" [disabled]="(unusedFormations$ | async)?.length == 0">
<ion-label i18n>Add travel duration</ion-label>
</ion-button>
</form>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="cancel()" i18n>Cancel</ion-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button form="updatePortalForm" type="submit" button-type="submit" [strong]="true" i18n
[disabled]="!updatePortal.form.valid">Confirm
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
</ng-template>
</ion-modal>
<app-typeahead [isOpen]="isFormationPopoverOpen" (dismissed)="isFormationPopoverOpen = false"
(itemSelected)="selectFormation($event)" [items$]="formations$" [usedItems]="usedFormations"
[event]="clickEvent"></app-typeahead>

View File

@ -0,0 +1,3 @@
.bold {
font-weight: bold;
}

View File

@ -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<UpdatePortalComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UpdatePortalComponent]
})
.compileComponents();
fixture = TestBed.createComponent(UpdatePortalComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<boolean> = new EventEmitter<boolean>();
@Output() updatedPortalChange: EventEmitter<Portal> = 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);
}
}

View File

@ -0,0 +1,142 @@
<ion-modal aria-labelledby="update-route-title" [isOpen]="isOpen"
(keyup.escape)="cancel()"
backdrop-dismiss="false">
<ng-template>
<ion-header>
<ion-toolbar>
<ion-title id="update-route-title" i18n>Update Route</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form id="updateRouteForm" #updateRoute="ngForm" (ngSubmit)="confirm()">
<ion-input
i18n-label
label="ID"
labelPlacement="stacked"
type="text"
[required]="true"
name="id"
[readonly]="true"
[(ngModel)]="route.id"
></ion-input>
<ion-input
i18n-label
label="Name"
labelPlacement="stacked"
type="text"
name="name"
[required]="true"
i18n-placeholder
placeholder="Name of the route"
[(ngModel)]="route.name"
></ion-input>
<ion-select
i18n-label
label="Country"
labelPlacement="stacked"
i18n-placeholder
placeholder="Country of the route"
name="country"
interface="popover"
[compareWith]="compareWithCountry"
[(ngModel)]="route.country"
>
<ion-select-option *ngFor="let country of countries$ | async"
[value]="country">
{{ country.name }}
</ion-select-option>
</ion-select>
<h6 i18n>Stations</h6>
<ion-list>
<ion-reorder-group [disabled]="false" (ionItemReorder)="handleReorderStations($event)">
<ion-item-sliding *ngFor="let station of route.stations; index as i; trackBy: trackBy">
<ion-item [button]="true" (click)="openPopoverStation($event, i)">
<ion-reorder slot="start"></ion-reorder>
<ion-label class="no-pointer-events">{{ station.name }}</ion-label>
</ion-item>
<ion-item-options>
<ion-item-option (click)="deleteStation(station)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-reorder-group>
</ion-list>
<ion-button (click)="openPopoverStation($event)" [disabled]="(unusedStations$ | async)?.length == 0">
<ion-label i18n class="no-pointer-events">Add station</ion-label>
</ion-button>
<h6 i18n>Portals</h6>
<ion-list>
<ion-item-sliding *ngFor="let portal of route.portals; index as i; trackBy: trackBy">
<ion-item>
<ion-label>{{ portal.name }}</ion-label>
</ion-item>
<ion-item-options>
<ion-item-option (click)="updatePortal(portal, i)" color="secondary" i18n>
<ion-icon slot="start" [ios]="'pencil-outline'" [md]="'pencil-sharp'"></ion-icon>
Update
</ion-item-option>
<ion-item-option (click)="deletePortal(portal)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-button (click)="addPortal()">
<ion-label i18n>Add portal</ion-label>
</ion-button>
<h6 i18n>Depots</h6>
<ion-list>
<ion-item-sliding *ngFor="let depot of route.depots; index as i; trackBy: trackBy">
<ion-item>
<ion-label>{{ depot.name }}</ion-label>
</ion-item>
<ion-item-options>
<ion-item-option (click)="updateDepot(depot, i)" color="secondary" i18n>
<ion-icon slot="start" [ios]="'pencil-outline'" [md]="'pencil-sharp'"></ion-icon>
Update
</ion-item-option>
<ion-item-option (click)="deleteDepot(depot)" color="danger" i18n>
<ion-icon slot="start" [ios]="'trash-outline'" [md]="'trash-sharp'"></ion-icon>
Delete
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-button (click)="addDepot()">
<ion-label i18n>Add depot</ion-label>
</ion-button>
</form>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="cancel()" i18n>Cancel</ion-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button form="updateRouteForm" type="submit" button-type="submit" [strong]="true" i18n
[disabled]="!updateRoute.form.valid || route.stations.length == 0">Confirm
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
</ng-template>
</ion-modal>
<app-create-portal [isOpen]="isCreatePortalModalOpen" (dismissed)="isCreatePortalModalOpen = false"
[stations]="route.stations" (createdPortal)="insertPortal($event)"></app-create-portal>
<app-update-portal [isOpen]="isUpdatePortalModalOpen" (dismissed)="isUpdatePortalModalOpen = false"
[stations]="route.stations" [(updatedPortal)]="updatedPortal"></app-update-portal>
<app-create-depot [isOpen]="isCreateDepotModalOpen" (dismissed)="isCreateDepotModalOpen = false"
[stations]="route.stations" (createdDepot)="insertDepot($event)"></app-create-depot>
<app-update-depot [isOpen]="isUpdateDepotModalOpen" (dismissed)="isUpdateDepotModalOpen = false"
[stations]="route.stations" [(updatedDepot)]="updatedDepot"></app-update-depot>
<app-typeahead [isOpen]="isStationPopoverOpen" (dismissed)="isStationPopoverOpen = false"
(itemSelected)="selectStation($event)" [usedItems]="route.stations" [items$]="stations$"
[event]="clickEvent"></app-typeahead>

View File

@ -0,0 +1,3 @@
.no-pointer-events {
pointer-events: none;
}

View File

@ -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<UpdateRouteComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UpdateRouteComponent]
})
.compileComponents();
fixture = TestBed.createComponent(UpdateRouteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<boolean> = new EventEmitter<boolean>();
@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);
}
}

View File

@ -0,0 +1,4 @@
export interface Item {
id: string;
name: string;
}

View File

@ -0,0 +1,18 @@
<ion-popover [isOpen]="isOpen" (ionPopoverDidDismiss)="dismissed.emit(true)" [event]="event" [side]="side"
[alignment]="alignment">
<ng-template>
<ion-content class="ion-padding">
<ion-searchbar [debounce]="debounce" (ionInput)="filterItems($event)"></ion-searchbar>
<ion-list [inset]="true">
<ion-item [button]="true" *ngFor="let item of filteredItems$ | async" (click)="selectItem(item)">
<ion-label>{{ item.name }}</ion-label>
</ion-item>
@if ((filteredItems$ | async)?.length == 0) {
<ion-item>
<ion-label i18n>No items available</ion-label>
</ion-item>
}
</ion-list>
</ion-content>
</ng-template>
</ion-popover>

View File

@ -0,0 +1,23 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {TypeaheadComponent} from './typeahead.component';
describe('TypeaheadComponent', () => {
let component: TypeaheadComponent;
let fixture: ComponentFixture<TypeaheadComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TypeaheadComponent]
})
.compileComponents();
fixture = TestBed.createComponent(TypeaheadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<T extends Item> implements OnChanges {
@Input() debounce: number = 300;
@Input() event: Event = new Event('');
@Input() alignment: PositionAlign = 'center';
@Input() side: PositionSide = 'bottom';
@Output() dismissed: EventEmitter<boolean> = new EventEmitter<boolean>();
@Output() itemSelected: EventEmitter<T> = new EventEmitter<T>();
@ViewChild('popover') popover: any;
@Input({required: true}) isOpen: boolean = false;
@Input({required: true}) items$!: Observable<T[]>;
@Input() usedItems: T[] = [];
_filteredItems!: Observable<T[]>;
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);
}
}