feat: Add route editing functionality (#20)
fix(formations): Don't overwrite existing formations in local storage
This commit is contained in:
parent
7e44fed534
commit
7a583d81a6
|
@ -197,10 +197,15 @@
|
|||
},
|
||||
"cli": {
|
||||
"schematicCollections": [
|
||||
"@schematics/angular",
|
||||
"@ionic/angular-toolkit"
|
||||
]
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"standalone": true,
|
||||
"style": "scss"
|
||||
},
|
||||
"@ionic/angular-toolkit:component": {
|
||||
"styleext": "scss"
|
||||
},
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
"@angular/localize": "^17.0.2",
|
||||
"@capacitor/cli": "5.5.1",
|
||||
"@ionic/angular-toolkit": "^9.0.0",
|
||||
"@schematics/angular": "^17.0.5",
|
||||
"@types/jasmine": "~4.3.0",
|
||||
"@types/node": "^12.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
|
@ -1317,6 +1318,22 @@
|
|||
"yarn": ">= 1.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli/node_modules/@schematics/angular": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.0.tgz",
|
||||
"integrity": "sha512-9jKU5x/WzaBsfSkUowK1X74FqtMXa6+A60XgW4ACO8i6fwKfPeS+tIrAieeYOX80/njBh7I5CvcpHmWA2SbcXQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "17.0.0",
|
||||
"@angular-devkit/schematics": "17.0.0",
|
||||
"jsonc-parser": "3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.13.0 || >=20.9.0",
|
||||
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||
"yarn": ">= 1.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-17.0.2.tgz",
|
||||
|
@ -4562,13 +4579,13 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/@schematics/angular": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.0.tgz",
|
||||
"integrity": "sha512-9jKU5x/WzaBsfSkUowK1X74FqtMXa6+A60XgW4ACO8i6fwKfPeS+tIrAieeYOX80/njBh7I5CvcpHmWA2SbcXQ==",
|
||||
"version": "17.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.5.tgz",
|
||||
"integrity": "sha512-sOc1UG4NiV+7cGwrbWPnyW71O+NgsKaFb2agSrVduRL7o4neMDeqF04ik4Kv1jKA7sZOQfPV+3cn6XI49Mumrw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "17.0.0",
|
||||
"@angular-devkit/schematics": "17.0.0",
|
||||
"@angular-devkit/core": "17.0.5",
|
||||
"@angular-devkit/schematics": "17.0.5",
|
||||
"jsonc-parser": "3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
|
@ -4577,6 +4594,63 @@
|
|||
"yarn": ">= 1.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@schematics/angular/node_modules/@angular-devkit/core": {
|
||||
"version": "17.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.0.5.tgz",
|
||||
"integrity": "sha512-e1evgRabAfOZBnmFCe8E0oufcu+FzBe5hBzS94Dm42GlxdX965/M4yVKQxIMpjivQTmjl+AWb6cF1ltBdSGZeQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ajv": "8.12.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"jsonc-parser": "3.2.0",
|
||||
"picomatch": "3.0.1",
|
||||
"rxjs": "7.8.1",
|
||||
"source-map": "0.7.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.13.0 || >=20.9.0",
|
||||
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||
"yarn": ">= 1.13.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chokidar": "^3.5.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"chokidar": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@schematics/angular/node_modules/@angular-devkit/schematics": {
|
||||
"version": "17.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.0.5.tgz",
|
||||
"integrity": "sha512-KYPku0qTb8B+TtRbFqXGYpJOPg1k6d5bNHV6n8jTc35mlEUUghOd7HkovdfkQ3cgGNQM56a74D1CvSeruZEGsA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "17.0.5",
|
||||
"jsonc-parser": "3.2.0",
|
||||
"magic-string": "0.30.5",
|
||||
"ora": "5.4.1",
|
||||
"rxjs": "7.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.13.0 || >=20.9.0",
|
||||
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||
"yarn": ">= 1.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@schematics/angular/node_modules/picomatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz",
|
||||
"integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@sigstore/bundle": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz",
|
||||
|
@ -18261,6 +18335,19 @@
|
|||
"semver": "7.5.4",
|
||||
"symbol-observable": "4.0.0",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@schematics/angular": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.0.tgz",
|
||||
"integrity": "sha512-9jKU5x/WzaBsfSkUowK1X74FqtMXa6+A60XgW4ACO8i6fwKfPeS+tIrAieeYOX80/njBh7I5CvcpHmWA2SbcXQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@angular-devkit/core": "17.0.0",
|
||||
"@angular-devkit/schematics": "17.0.0",
|
||||
"jsonc-parser": "3.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@angular/common": {
|
||||
|
@ -20503,14 +20590,49 @@
|
|||
"dev": true
|
||||
},
|
||||
"@schematics/angular": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.0.tgz",
|
||||
"integrity": "sha512-9jKU5x/WzaBsfSkUowK1X74FqtMXa6+A60XgW4ACO8i6fwKfPeS+tIrAieeYOX80/njBh7I5CvcpHmWA2SbcXQ==",
|
||||
"version": "17.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.5.tgz",
|
||||
"integrity": "sha512-sOc1UG4NiV+7cGwrbWPnyW71O+NgsKaFb2agSrVduRL7o4neMDeqF04ik4Kv1jKA7sZOQfPV+3cn6XI49Mumrw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@angular-devkit/core": "17.0.0",
|
||||
"@angular-devkit/schematics": "17.0.0",
|
||||
"@angular-devkit/core": "17.0.5",
|
||||
"@angular-devkit/schematics": "17.0.5",
|
||||
"jsonc-parser": "3.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": {
|
||||
"version": "17.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.0.5.tgz",
|
||||
"integrity": "sha512-e1evgRabAfOZBnmFCe8E0oufcu+FzBe5hBzS94Dm42GlxdX965/M4yVKQxIMpjivQTmjl+AWb6cF1ltBdSGZeQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ajv": "8.12.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"jsonc-parser": "3.2.0",
|
||||
"picomatch": "3.0.1",
|
||||
"rxjs": "7.8.1",
|
||||
"source-map": "0.7.4"
|
||||
}
|
||||
},
|
||||
"@angular-devkit/schematics": {
|
||||
"version": "17.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.0.5.tgz",
|
||||
"integrity": "sha512-KYPku0qTb8B+TtRbFqXGYpJOPg1k6d5bNHV6n8jTc35mlEUUghOd7HkovdfkQ3cgGNQM56a74D1CvSeruZEGsA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@angular-devkit/core": "17.0.5",
|
||||
"jsonc-parser": "3.2.0",
|
||||
"magic-string": "0.30.5",
|
||||
"ora": "5.4.1",
|
||||
"rxjs": "7.8.1"
|
||||
}
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz",
|
||||
"integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@sigstore/bundle": {
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
"@angular/localize": "^17.0.2",
|
||||
"@capacitor/cli": "5.5.1",
|
||||
"@ionic/angular-toolkit": "^9.0.0",
|
||||
"@schematics/angular": "^17.0.5",
|
||||
"@types/jasmine": "~4.3.0",
|
||||
"@types/node": "^12.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
|
|
|
@ -6,6 +6,8 @@ import {provideEffects} from "@ngrx/effects";
|
|||
import {formationsReducer} from "./formations/store/formations.reducer";
|
||||
import {subscriptionReducer} from "./subscription/store/subscription.reducer";
|
||||
import {subscriptionEffects, subscriptionFeature} from "./subscription/store";
|
||||
import {routesReducer} from "./routes/store/routes.reducer";
|
||||
import {featureStateName as routesFeature, routesEffects} from "./routes/store";
|
||||
|
||||
export const ROOT_ROUTES: Routes = [
|
||||
{
|
||||
|
@ -46,7 +48,12 @@ export const ROOT_ROUTES: Routes = [
|
|||
{
|
||||
path: 'routes',
|
||||
loadComponent: () => import("./routes/routes.component").then(mod => mod.RoutesComponent),
|
||||
canActivate: [AppAuthGuard]
|
||||
canActivate: [AppAuthGuard],
|
||||
providers: [
|
||||
provideState(routesFeature, routesReducer),
|
||||
provideState(formationsFeature, formationsReducer),
|
||||
provideEffects(routesEffects, formationsEffects)
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'timetables',
|
||||
|
@ -68,7 +75,8 @@ export const ROOT_ROUTES: Routes = [
|
|||
canActivate: [AppAuthGuard],
|
||||
providers: [
|
||||
provideState(formationsFeature, formationsReducer),
|
||||
provideEffects(formationsEffects)
|
||||
provideState(routesFeature, routesReducer),
|
||||
provideEffects(formationsEffects, routesEffects)
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -9,7 +9,12 @@
|
|||
<ion-content [fullscreen]="true">
|
||||
<ion-list>
|
||||
<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-item>
|
||||
<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 ion-text-end"># Stations</ion-label>
|
||||
</ion-item>
|
||||
<ion-item-sliding *ngFor="let route of routes">
|
||||
<ion-item-sliding *ngFor="let route of routes$ | async">
|
||||
<ion-item>
|
||||
<ion-label>{{ route.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-item>
|
||||
<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>
|
||||
Delete
|
||||
</ion-item-option>
|
||||
|
@ -99,14 +108,19 @@
|
|||
<ion-fab-button i18n-aria-label aria-label="Add timetable" [show]="true">
|
||||
<ion-icon [ios]="'time-outline'" [md]="'time-sharp'"></ion-icon>
|
||||
</ion-fab-button>
|
||||
<ion-fab-button i18n-aria-label aria-label="Add route" [show]="true">
|
||||
<ion-icon [ios]="'map-outline'" [md]="'map-sharp'"></ion-icon>
|
||||
</ion-fab-button>
|
||||
@if ((routes$ | async)?.length == 0 || (hasPersonalPlan$ | async)) {
|
||||
<ion-fab-button i18n-aria-label aria-label="Add route" [show]="true" (click)="addRoute()">
|
||||
<ion-icon [ios]="'map-outline'" [md]="'map-sharp'"></ion-icon>
|
||||
</ion-fab-button>
|
||||
}
|
||||
</ion-fab-list>
|
||||
</ion-fab>
|
||||
<app-create-formation [isOpen]="isCreateFormationModalOpen"
|
||||
(dismissed)="isCreateFormationModalOpen = false"></app-create-formation>
|
||||
<app-update-formation [isOpen]="isUpdateFormationModalOpen" [updatedFormation]="updatedFormation!"
|
||||
(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-footer id="footer"></ion-footer>
|
||||
|
|
|
@ -40,7 +40,7 @@ import {
|
|||
trashOutline,
|
||||
trashSharp
|
||||
} from "ionicons/icons";
|
||||
import {Route} from "../routes/model/route";
|
||||
import {DEFAULT_ROUTE, Route} from "../routes/model/route";
|
||||
import {Timetable} from "../timetables/model/timetable";
|
||||
import {FormationsStoreService} from "../formations/service/formations-store.service";
|
||||
import {DEFAULT_FORMATION, Formation} from "../formations/model/formation";
|
||||
|
@ -53,6 +53,12 @@ import {ActivatedRoute, EventType, NavigationEnd, Router} from "@angular/router"
|
|||
import {addMessageAction} from "../messages/store/messages.actions";
|
||||
import {Message} from "../messages/model/message";
|
||||
import {MessagesState} from "../messages/store/messages.reducer";
|
||||
import {CreateRouteComponent} from "../routes/create-route/create-route.component";
|
||||
import {UpdateRouteComponent} from "../routes/update-route/update-route.component";
|
||||
import {RoutesStoreService} from "../routes/service/routes-store.service";
|
||||
import {RoutesState} from "../routes/store/routes.reducer";
|
||||
import {deleteRouteAction} from "../routes/store/routes.actions";
|
||||
import {AuthService} from "../auth/service/auth.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
|
@ -85,19 +91,12 @@ import {MessagesState} from "../messages/store/messages.reducer";
|
|||
IonFabList,
|
||||
IonFooter,
|
||||
CreateFormationComponent,
|
||||
UpdateFormationComponent
|
||||
UpdateFormationComponent,
|
||||
CreateRouteComponent,
|
||||
UpdateRouteComponent
|
||||
]
|
||||
})
|
||||
export class DashboardComponent implements OnDestroy {
|
||||
routes: Route[] = [
|
||||
{
|
||||
name: 'Köln-Aachen',
|
||||
country: {name: $localize`Germany`, code: 'de'},
|
||||
firstStation: {name: 'Köln Hbf'},
|
||||
lastStation: {name: 'Aachen Hbf'},
|
||||
numberOfStations: 30
|
||||
}
|
||||
];
|
||||
timetables: Timetable[] = [];
|
||||
|
||||
isCreateFormationModalOpen = false;
|
||||
|
@ -106,6 +105,19 @@ export class DashboardComponent implements OnDestroy {
|
|||
private readonly formationsStoreService: FormationsStoreService = inject(FormationsStoreService);
|
||||
formations$ = this.formationsStoreService.getFormations$();
|
||||
|
||||
isCreateRouteModalOpen = false;
|
||||
isUpdateRouteModalOpen = false;
|
||||
updatedRoute: Route = DEFAULT_ROUTE;
|
||||
private readonly routesStoreService: RoutesStoreService = inject(RoutesStoreService);
|
||||
routes$ = this.routesStoreService.getRoutes$();
|
||||
|
||||
private readonly authService: AuthService = inject(AuthService);
|
||||
readonly user$ = this.authService.getUser$();
|
||||
readonly hasPersonalPlan$ = this.user$.pipe(
|
||||
map(user => user.roles),
|
||||
filter(roles => roles.includes('PERSONAL_PLAN'))
|
||||
);
|
||||
|
||||
private messages: Record<string, Message> = {
|
||||
success: {
|
||||
text: $localize`You have successfully subscribed. The subscription can be managed from the account settings.`,
|
||||
|
@ -117,6 +129,7 @@ export class DashboardComponent implements OnDestroy {
|
|||
private subscription: Subscription;
|
||||
|
||||
constructor(private readonly formationsStore: Store<FormationsState>,
|
||||
private readonly routesStore: Store<RoutesState>,
|
||||
private readonly messagesStore: Store<MessagesState>,
|
||||
private readonly activatedRoute: ActivatedRoute,
|
||||
private readonly router: Router) {
|
||||
|
@ -160,6 +173,19 @@ export class DashboardComponent implements OnDestroy {
|
|||
this.formationsStore.dispatch(deleteFormationAction({payload: formation}));
|
||||
}
|
||||
|
||||
addRoute() {
|
||||
this.isCreateRouteModalOpen = true;
|
||||
}
|
||||
|
||||
updateRoute(route: Route) {
|
||||
this.isUpdateRouteModalOpen = true;
|
||||
this.updatedRoute = route;
|
||||
}
|
||||
|
||||
deleteRoute(route: Route) {
|
||||
this.routesStore.dispatch(deleteRouteAction({payload: route}));
|
||||
}
|
||||
|
||||
private triggerFeedbackMessage() {
|
||||
const state = this.activatedRoute.snapshot.queryParamMap.get('state') || '';
|
||||
if (state in this.messages) {
|
||||
|
|
|
@ -61,7 +61,7 @@ import {FormationsState} from "./store/formations.reducer";
|
|||
export class FormationsComponent {
|
||||
isCreateModalOpen = false;
|
||||
isUpdateModalOpen = false;
|
||||
updatedFormation: Formation = DEFAULT_FORMATION;
|
||||
updatedFormation: Formation = {...DEFAULT_FORMATION};
|
||||
|
||||
private readonly storeService: FormationsStoreService = inject(FormationsStoreService);
|
||||
formations$ = this.storeService.getFormations$();
|
||||
|
@ -82,8 +82,8 @@ export class FormationsComponent {
|
|||
}
|
||||
|
||||
updateFormation(formation: Formation) {
|
||||
this.isUpdateModalOpen = true;
|
||||
this.updatedFormation = formation;
|
||||
this.isUpdateModalOpen = true;
|
||||
}
|
||||
|
||||
deleteFormation(formation: Formation) {
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
export interface Formation {
|
||||
import {Item} from "../../typeahead/item";
|
||||
|
||||
export interface Formation extends Item {
|
||||
id: string;
|
||||
name: string;
|
||||
trainSimWorldFormation?: Formation;
|
||||
|
|
|
@ -25,6 +25,7 @@ export class FormationsService {
|
|||
fetchFormations(): Observable<Formation[]> {
|
||||
if (environment.mockNetwork) {
|
||||
this.formations = JSON.parse(localStorage.getItem("formations") || '[]');
|
||||
this.formations.forEach(formation => this.knownFormations.set(formation.id, formation));
|
||||
return of(this.formations);
|
||||
}
|
||||
|
||||
|
|
|
@ -48,10 +48,10 @@ import {FormationsState} from "../store/formations.reducer";
|
|||
export class UpdateFormationComponent {
|
||||
@ViewChild(IonModal) modal: IonModal | undefined;
|
||||
|
||||
@Input({required: true}) isOpen: boolean = false;
|
||||
@Output() dismissed: EventEmitter<boolean> = new EventEmitter<boolean>();
|
||||
|
||||
formation: Formation = {...DEFAULT_FORMATION};
|
||||
formationOnOpen: Formation = {...this.formation};
|
||||
|
||||
private readonly store: Store<FormationsState> = inject(Store<FormationsState>);
|
||||
private readonly storeService: FormationsStoreService = inject(FormationsStoreService);
|
||||
|
@ -60,13 +60,26 @@ export class UpdateFormationComponent {
|
|||
constructor() {
|
||||
}
|
||||
|
||||
private _isOpen = false;
|
||||
@Input({required: true}) set isOpen(newValue: boolean) {
|
||||
if (!this._isOpen && newValue) {
|
||||
this.formationOnOpen = {...this.formation};
|
||||
}
|
||||
this._isOpen = newValue;
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this._isOpen;
|
||||
}
|
||||
|
||||
@Input({required: true}) set updatedFormation(newValue: Formation) {
|
||||
this.formation = {...newValue};
|
||||
this.formationOnOpen = {...newValue};
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dismissed.emit(true);
|
||||
this.formation = {...DEFAULT_FORMATION};
|
||||
this.formation = {...this.formationOnOpen};
|
||||
}
|
||||
|
||||
confirm() {
|
||||
|
@ -74,7 +87,6 @@ export class UpdateFormationComponent {
|
|||
payload: {...this.formation}
|
||||
}))
|
||||
this.dismissed.emit(true);
|
||||
this.formation = {...DEFAULT_FORMATION};
|
||||
}
|
||||
|
||||
compareWith(formation1: Formation, formation2: Formation) {
|
||||
|
|
|
@ -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)
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
.no-pointer-events {
|
||||
pointer-events: none;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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: []
|
||||
};
|
|
@ -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: []
|
||||
};
|
|
@ -1,14 +1,31 @@
|
|||
import {Station} from "./station";
|
||||
import {DEFAULT_STATION, Station} from "./station";
|
||||
import {Country} from "./country";
|
||||
import {Depot} from "./depot";
|
||||
import {Portal} from "./portal";
|
||||
|
||||
export interface Route {
|
||||
id: string;
|
||||
name: string;
|
||||
country: Country;
|
||||
stations: Station[];
|
||||
firstStation: Station;
|
||||
lastStation: Station;
|
||||
numberOfStations: number;
|
||||
depots: Depot[];
|
||||
portals: Portal[];
|
||||
}
|
||||
|
||||
export interface EditedRoute extends Route {
|
||||
stations: Station[];
|
||||
export const DEFAULT_ROUTE: Route = {
|
||||
id: "",
|
||||
name: "",
|
||||
country: {
|
||||
code: 'de',
|
||||
name: 'Germany'
|
||||
},
|
||||
firstStation: DEFAULT_STATION,
|
||||
lastStation: DEFAULT_STATION,
|
||||
stations: [],
|
||||
numberOfStations: 0,
|
||||
depots: [],
|
||||
portals: []
|
||||
}
|
||||
|
|
|
@ -1,3 +1,11 @@
|
|||
export interface Station {
|
||||
import {Item} from "../../typeahead/item";
|
||||
|
||||
export interface Station extends Item {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_STATION: Station = {
|
||||
id: '',
|
||||
name: ''
|
||||
};
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import {Formation} from "../../formations/model/formation";
|
||||
|
||||
export interface TravelDuration {
|
||||
formation: Formation;
|
||||
time: number;
|
||||
}
|
|
@ -3,7 +3,12 @@
|
|||
<ion-buttons slot="start">
|
||||
<ion-menu-button></ion-menu-button>
|
||||
</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-header>
|
||||
<ion-content [fullscreen]="true">
|
||||
|
@ -15,8 +20,8 @@
|
|||
<ion-label i18n class="bold">Last Station</ion-label>
|
||||
<ion-label i18n class="bold"># Stations</ion-label>
|
||||
</ion-item>
|
||||
<ion-item-sliding>
|
||||
<ion-item *ngFor="let route of routes">
|
||||
<ion-item-sliding *ngFor="let route of routes$ | async">
|
||||
<ion-item>
|
||||
<ion-label>{{ route.name }}</ion-label>
|
||||
<ion-label>{{ route.country.name }}</ion-label>
|
||||
<ion-label>{{ route.firstStation.name }}</ion-label>
|
||||
|
@ -24,17 +29,26 @@
|
|||
<ion-label>{{ route.numberOfStations }}</ion-label>
|
||||
</ion-item>
|
||||
<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>
|
||||
Delete
|
||||
</ion-item-option>
|
||||
</ion-item-options>
|
||||
</ion-item-sliding>
|
||||
</ion-list>
|
||||
<ion-fab slot="fixed" horizontal="end" vertical="bottom">
|
||||
<ion-fab-button i18n-aria-label aria-label="Add route">
|
||||
<ion-icon [ios]="'add-outline'" [md]="'add-sharp'"></ion-icon>
|
||||
</ion-fab-button>
|
||||
</ion-fab>
|
||||
@if ((routes$ | async)?.length == 0 || (hasPersonalPlan$ | async)) {
|
||||
<ion-fab slot="fixed" horizontal="end" vertical="bottom">
|
||||
<ion-fab-button i18n-aria-label aria-label="Add route" (click)="addRoute()">
|
||||
<ion-icon [ios]="'add-outline'" [md]="'add-sharp'"></ion-icon>
|
||||
</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-footer></ion-footer>
|
||||
<ion-footer id="footer"></ion-footer>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import {Component} from '@angular/core';
|
||||
import {Route} from "./model/route";
|
||||
import {Component, inject} from '@angular/core';
|
||||
import {DEFAULT_ROUTE, Route} from "./model/route";
|
||||
import {addIcons} from "ionicons";
|
||||
import {addOutline, addSharp, trashOutline, trashSharp} from "ionicons/icons";
|
||||
import {NgForOf} from "@angular/common";
|
||||
import {addOutline, addSharp, pencilOutline, pencilSharp, trashOutline, trashSharp} from "ionicons/icons";
|
||||
import {AsyncPipe, NgForOf} from "@angular/common";
|
||||
import {
|
||||
IonButtons,
|
||||
IonContent,
|
||||
|
@ -22,6 +22,16 @@ import {
|
|||
IonTitle,
|
||||
IonToolbar
|
||||
} from "@ionic/angular/standalone";
|
||||
import {RoutesStoreService} from "./service/routes-store.service";
|
||||
import {deleteRouteAction} from "./store/routes.actions";
|
||||
import {Store} from "@ngrx/store";
|
||||
import {RoutesState} from "./store/routes.reducer";
|
||||
import {CreateFormationComponent} from "../formations/create-formation/create-formation.component";
|
||||
import {UpdateFormationComponent} from "../formations/update-formation/update-formation.component";
|
||||
import {CreateRouteComponent} from "./create-route/create-route.component";
|
||||
import {UpdateRouteComponent} from "./update-route/update-route.component";
|
||||
import {AuthService} from "../auth/service/auth.service";
|
||||
import {filter, map} from "rxjs";
|
||||
|
||||
@Component({
|
||||
selector: 'app-routes',
|
||||
|
@ -46,27 +56,50 @@ import {
|
|||
IonIcon,
|
||||
IonFab,
|
||||
IonFabButton,
|
||||
IonFooter
|
||||
IonFooter,
|
||||
AsyncPipe,
|
||||
CreateFormationComponent,
|
||||
UpdateFormationComponent,
|
||||
CreateRouteComponent,
|
||||
UpdateRouteComponent
|
||||
]
|
||||
})
|
||||
export class RoutesComponent {
|
||||
isCreateModalOpen = false;
|
||||
isUpdateModalOpen = false;
|
||||
updatedRoute: Route = {...DEFAULT_ROUTE};
|
||||
|
||||
routes: Route[] = [
|
||||
{
|
||||
name: 'Köln-Aachen',
|
||||
country: {name: $localize`Germany`, code: 'de'},
|
||||
firstStation: {name: 'Köln Hbf'},
|
||||
lastStation: {name: 'Aachen Hbf'},
|
||||
numberOfStations: 30
|
||||
}
|
||||
];
|
||||
private readonly storeService: RoutesStoreService = inject(RoutesStoreService);
|
||||
routes$ = this.storeService.getRoutes$();
|
||||
|
||||
constructor() {
|
||||
private readonly authService: AuthService = inject(AuthService);
|
||||
readonly user$ = this.authService.getUser$();
|
||||
readonly hasPersonalPlan$ = this.user$.pipe(
|
||||
map(user => user.roles),
|
||||
filter(roles => roles.includes('PERSONAL_PLAN'))
|
||||
);
|
||||
|
||||
constructor(private readonly store: Store<RoutesState>) {
|
||||
addIcons({
|
||||
addOutline,
|
||||
addSharp,
|
||||
trashOutline,
|
||||
trashSharp,
|
||||
pencilOutline,
|
||||
pencilSharp,
|
||||
});
|
||||
}
|
||||
|
||||
addRoute() {
|
||||
this.isCreateModalOpen = true;
|
||||
}
|
||||
|
||||
updateRoute(route: Route) {
|
||||
this.updatedRoute = route;
|
||||
this.isUpdateModalOpen = true;
|
||||
}
|
||||
|
||||
deleteRoute(route: Route) {
|
||||
this.store.dispatch(deleteRouteAction({payload: route}));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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', []))
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
|
@ -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
|
||||
);
|
|
@ -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});
|
|
@ -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
|
||||
})
|
||||
}))
|
||||
);
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
.no-pointer-events {
|
||||
pointer-events: none;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue