Merged PR 1185: #2982 #2983 #3028 #3031 Remission Liste Caching, Scrollposition, Loader, Virtual Scroll Viewport

#2982 #2983 #3028 #3031 Remission Liste Caching, Scrollposition, Loader, Virtual Scroll Viewport
This commit is contained in:
Nino Righi
2022-04-20 16:36:27 +00:00
committed by Lorenz Hilpert
parent c4ed8d0648
commit f657a088d4
24 changed files with 707 additions and 301 deletions

View File

@@ -51,7 +51,7 @@ export class CacheService {
return cached.data;
}
private delete(token: Object, from: 'session' | 'persist' = 'session') {
delete(token: Object, from: 'session' | 'persist' = 'session') {
if (from === 'session') {
sessionStorage.removeItem(this.getKey(token));
} else if (from === 'persist') {

View File

@@ -209,7 +209,7 @@ export class DomainRemissionService {
.pipe(
map((res) => {
const o = items.map((item) => {
const stockInfo = res.result.find((stockInfo) => stockInfo.itemId === +item.dto.product.catalogProductNumber);
const stockInfo = res?.result?.find((stockInfo) => stockInfo.itemId === +item.dto.product.catalogProductNumber);
if (!stockInfo) {
const defaultStockData = {

View File

@@ -1,3 +1,4 @@
// start:ng42.barrel
export * from './remission-list.component';
export * from './remission-list-item-loading';
// end:ng42.barrel

View File

@@ -27,6 +27,7 @@ export class RemissionFilterComponent implements OnDestroy {
applyFilter(filter: UiFilter) {
this.store.setFilter(filter);
this.store._filterChange$.next(true);
this.close.emit();
}

View File

@@ -0,0 +1,3 @@
// start:ng42.barrel
export * from './remission-list-item-loading.component';
// end:ng42.barrel

View File

@@ -0,0 +1,47 @@
<div class="col">
<div class="row">
<div class="contributors animation"></div>
<div class="product-group animation"></div>
</div>
<div class="row">
<div class="title-large animation"></div>
<div class="department animation"></div>
</div>
<div class="row">
<div class="title animation"></div>
<div class="department animation"></div>
</div>
<div class="space"></div>
</div>
<div class="col">
<div class="row">
<div class="thumbnail animation"></div>
<div class="col ml-4">
<div class="row align-left">
<div class="left animation"></div>
<div class="right-small animation"></div>
</div>
<div class="row align-left">
<div class="left animation"></div>
<div class="right-small animation"></div>
</div>
<div class="row">
<div class="left-small animation"></div>
<div class="right animation"></div>
</div>
<div class="row">
<div class="left-small animation"></div>
<div class="right animation"></div>
</div>
<div class="row">
<div></div>
<div class="right animation"></div>
</div>
</div>
</div>
<div class="space"></div>
<div class="row">
<div></div>
<div class="right-extra-small animation"></div>
</div>
</div>

View File

@@ -0,0 +1,92 @@
:host {
@apply grid grid-flow-row gap-2 p-4 bg-white rounded-card w-full;
height: 368px;
max-height: 368px;
}
.animation {
@apply bg-ucla-blue h-6;
animation: load 1s linear infinite;
}
.thumbnail {
width: 70px;
@apply rounded-card h-px-100;
}
.space {
@apply flex-grow h-2;
}
.col {
@apply flex flex-col flex-grow;
}
.row {
@apply flex flex-row justify-between flex-grow;
.align-left {
@apply justify-start;
.left {
margin-right: 10%;
}
}
}
.author {
width: 17.5%;
}
.title {
width: 50%;
}
.title-large {
width: 60%;
}
.contributors {
width: 20%;
}
.product-group {
width: 25%;
}
.department {
width: 20%;
}
.left {
width: 30%;
}
.left-small {
text-align: left;
width: 15%;
}
.right {
width: 60%;
}
.right-small {
width: 25%;
}
.right-extra-small {
width: 15%;
}
@keyframes load {
0% {
opacity: 0;
}
30% {
opacity: 0.5;
}
100% {
opacity: 0;
}
}

View File

@@ -0,0 +1,11 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'page-remission-list-item-loading',
templateUrl: 'remission-list-item-loading.component.html',
styleUrls: ['remission-list-item-loading.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RemissionListItemLoadingComponent {
constructor() {}
}

View File

@@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { RemissionListItemLoadingComponent } from './remission-list-item-loading.component';
@NgModule({
imports: [],
exports: [RemissionListItemLoadingComponent],
declarations: [RemissionListItemLoadingComponent],
providers: [],
})
export class RemissionListItemLoadingModule {}

View File

@@ -1,8 +1,23 @@
<div class="grid grid-flow-col gap-8 justify-between items-start">
<h3 class="font-bold text-xl">{{ item.title }}</h3>
<span class="mt-px-2 whitespace-nowrap overflow-hidden overflow-ellipsis w-36 text-active-branch"
<div class="item-header-details grid grid-flow-col justify-between w-full">
<div class="contributors text-black font-bold overflow-hidden text-base overflow-ellipsis whitespace-nowrap">
<a [class.mr-2]="!last" *ngFor="let contributor of contributors; let last = last">{{ last ? contributor : contributor + ';' }}</a>
</div>
<h3
class="title font-bold text-2xl"
[class.xl]="item?.dto?.product?.name?.length >= 35"
[class.lg]="item?.dto?.product?.name?.length >= 40"
[class.md]="item?.dto?.product?.name?.length >= 50"
[class.sm]="item?.dto?.product?.name?.length >= 60"
[class.xs]="item?.dto?.product?.name?.length >= 100"
>
{{ item.title }}
</h3>
<span class="product-group block text-right whitespace-nowrap overflow-hidden overflow-ellipsis text-active-branch"
>{{ item.productGroup }}:{{ item.productGroup | productGroup }}</span
>
<div class="department block text-right text-inactive-branch overflow-hidden overflow-ellipsis whitespace-nowrap">
{{ item.department }}
</div>
</div>
<div
*ngIf="!!returnDto && (item?.dto?.descendantOf?.enabled || item?.dto?.impediment)"
@@ -62,22 +77,16 @@
<div class="font-bold">{{ item.remissionReason }}</div>
</div>
</div>
<div class="grid grid-flow-row gap-1 justify-self-end">
<div class="text-right text-inactive-branch overflow-hidden overflow-ellipsis whitespace-nowrap">
{{ item.department }}
</div>
</div>
</div>
<ng-container *ngIf="!!returnDto; else removeItem">
<div class="grid grid-flow-col justify-self-end gap-2">
<button *ngIf="enableChangeRemissionQuantity" class="text-brand text-lg font-bold px-6 py-3" (click)="addProductToShippingDocument()">
<div class="grid grid-flow-col justify-self-end gap-2 items-center">
<button *ngIf="enableChangeRemissionQuantity" class="text-brand text-base font-bold px-6 py-3" (click)="addProductToShippingDocument()">
Remi-Menge / Platzierung ändern
</button>
<button
*ngIf="enableToRemit"
class="bg-white border-brand border-solid border-2 text-brand font-bold text-lg px-6 py-3 rounded-full"
class="bg-white border-brand border-solid border-2 text-brand font-bold text-base px-6 py-3 rounded-full h-12"
(click)="remit()"
>
Remittieren
@@ -87,7 +96,7 @@
<ng-template #removeItem>
<div *ngIf="item?.dto?.source === 'manually-added'" class="grid grid-flow-col justify-self-end gap-2">
<button class="text-brand text-lg font-bold px-6 py-3" (click)="removeReturnItem()">
<button class="text-brand text-base font-bold px-6 py-3" (click)="removeReturnItem()">
Entfernen
</button>
</div>

View File

@@ -1,7 +1,55 @@
:host {
@apply grid grid-flow-row gap-2 p-4 bg-white rounded-card;
@apply grid grid-flow-row gap-2 p-4 bg-white rounded-card w-full;
height: 368px;
max-height: 368px;
}
.item-header-details {
grid-template-columns: auto 9rem;
grid-template-rows: auto;
grid-template-areas:
'contributors product-group'
'title department';
}
.contributors {
grid-area: contributors;
max-width: 95%;
}
.title {
grid-area: title;
max-width: 95%;
}
.title.xl {
@apply font-bold text-xl;
}
.title.lg {
@apply font-bold text-lg;
}
.title.md {
@apply font-bold text-base;
}
.title.sm {
@apply font-bold text-sm;
}
.title.xs {
@apply font-bold text-xs;
}
.product-group {
grid-area: product-group;
}
.department {
grid-area: department;
}
.product-data-grid {
grid-template-columns: 90px 190px 340px auto;
grid-template-columns: 90px 190px auto;
}

View File

@@ -23,6 +23,10 @@ export class RemissionListItemComponent implements OnDestroy {
@Input()
returnDto: ReturnDTO;
get contributors() {
return this.item?.dto?.product?.contributors?.split(';').map((val) => val.trim());
}
get firstReceipt() {
return this.returnDto?.receipts?.find((_) => true)?.data;
}

View File

@@ -1,9 +1,13 @@
import { Injectable, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BreadcrumbService } from '@core/breadcrumb';
import { CacheService } from '@core/cache';
import { Config } from '@core/config';
import { DomainRemissionService, RemissionListItem } from '@domain/remission';
import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { SupplierDTO } from '@swagger/remi';
import { UiFilter } from '@ui/filter';
import { combineLatest, Observable, Subject } from 'rxjs';
import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
import { debounceTime, filter, switchMap, takeUntil, tap, withLatestFrom } from 'rxjs/operators';
export interface RemissionState {
@@ -23,10 +27,26 @@ export interface RemissionState {
export class RemissionListComponentStore extends ComponentStore<RemissionState> implements OnDestroy {
private _onDestroy$ = new Subject<void>();
private _searchCompleted = new Subject<RemissionState>();
private _searchCompleted = new BehaviorSubject<RemissionState>({
suppliers: [],
sources: [],
hits: 0,
items: [],
requiredCapacities: undefined,
});
searchCompleted = this._searchCompleted.asObservable();
_sourceOrSupplierChange$ = new BehaviorSubject<boolean>(false);
_filterChange$ = new BehaviorSubject<boolean>(false);
scroll = new Subject<number>();
get processId() {
return this._config.get('process.ids.remission');
}
get suppliers() {
return this.get((s) => s.suppliers);
}
@@ -103,7 +123,14 @@ export class RemissionListComponentStore extends ComponentStore<RemissionState>
return this.select((s) => s.searchOptions);
}
constructor(private readonly _domainRemissionService: DomainRemissionService) {
constructor(
private readonly _domainRemissionService: DomainRemissionService,
private readonly _config: Config,
private readonly _cache: CacheService,
private readonly _activatedRoute: ActivatedRoute,
private readonly _router: Router,
private readonly _breadcrumb: BreadcrumbService
) {
super({
suppliers: [],
sources: [],
@@ -111,7 +138,6 @@ export class RemissionListComponentStore extends ComponentStore<RemissionState>
items: [],
requiredCapacities: undefined,
});
this.loadSuppliers();
this.loadSources();
this.initLoadFilter();
@@ -132,9 +158,14 @@ export class RemissionListComponentStore extends ComponentStore<RemissionState>
}
initSearch() {
combineLatest([this.selectedSource$, this.selectedSupplier$, this.filter$])
combineLatest([this._sourceOrSupplierChange$, this._filterChange$, this._activatedRoute.queryParams])
.pipe(debounceTime(500), takeUntil(this._onDestroy$))
.subscribe(() => this.search({}));
.subscribe(([change, filter, queryParams]) => {
const data = this.getCachedData(queryParams);
if (change || data.items?.length === 0 || filter) {
this.search({ newSearch: true });
}
});
}
// tslint:disable: member-ordering
@@ -204,6 +235,7 @@ export class RemissionListComponentStore extends ComponentStore<RemissionState>
settings?.filter?.forEach((filter) => (filter.input = filter.input?.filter((input) => input.options?.values?.length > 0)));
this.setFilter(UiFilter.create(settings));
this.items ? this.setFetching(false) : '';
},
(err) => {}
)
@@ -260,7 +292,6 @@ export class RemissionListComponentStore extends ComponentStore<RemissionState>
...state,
suppliers,
selectedSupplierId: state.selectedSupplierId ?? suppliers[0]?.id,
items: [],
}));
setSelectedSupplierId = this.updater<number>((state, supplierId) => {
@@ -270,7 +301,6 @@ export class RemissionListComponentStore extends ComponentStore<RemissionState>
return {
...state,
selectedSupplierId: supplierId ?? state.selectedSupplierId ?? state.suppliers[0]?.id,
items: [],
};
});
@@ -279,7 +309,6 @@ export class RemissionListComponentStore extends ComponentStore<RemissionState>
...state,
sources,
selectedSource: state.selectedSource ? sources.find((source) => source === state.selectedSource) : sources[0],
items: [],
};
});
@@ -291,7 +320,6 @@ export class RemissionListComponentStore extends ComponentStore<RemissionState>
setFilter = this.updater<UiFilter>((state, filter) => ({
...state,
filter,
items: [],
}));
setItems = this.updater<RemissionListItem[]>((state, items) => ({
@@ -299,6 +327,11 @@ export class RemissionListComponentStore extends ComponentStore<RemissionState>
items,
}));
setHits = this.updater<number>((state, hits) => ({
...state,
hits,
}));
removeItem = this.updater<RemissionListItem>((state, item) => ({
...state,
items: state.items.filter((i) => i.dto?.id !== item.dto?.id),
@@ -309,4 +342,59 @@ export class RemissionListComponentStore extends ComponentStore<RemissionState>
requiredCapacities,
fetching: false,
}));
cacheCurrentData(params: Record<string, string> = {}) {
const qparams = this.cleanupQueryParams({ ...params, processId: String(this.processId) });
this._cache.set(qparams, {
items: this.items,
hits: this.hits,
});
}
getCachedData(params: Record<string, string> = {}) {
const qparams = this.cleanupQueryParams({ ...params, processId: String(this.processId) });
return (
this._cache.get<{
items: RemissionListItem[];
hits: number;
}>(qparams) || { items: [], hits: 0 }
);
}
cleanupQueryParams(params: Record<string, string> = {}) {
const clean = { ...params };
delete clean['scroll_position'];
for (const key in clean) {
if (Object.prototype.hasOwnProperty.call(clean, key)) {
if (clean[key] == undefined) {
delete clean[key];
}
}
}
return clean;
}
setSupplier(supplier: SupplierDTO) {
this._router.navigate([], {
queryParams: {
supplier: supplier.id,
},
queryParamsHandling: 'merge',
});
this._sourceOrSupplierChange$.next(true);
}
setSource(source: string) {
this._router.navigate([], {
queryParams: {
source,
},
queryParamsHandling: 'merge',
});
this._sourceOrSupplierChange$.next(true);
}
}

View File

@@ -7,92 +7,78 @@
<page-remission-filter *ngIf="shellFilterOverlay.isOpen" (close)="shellFilterOverlay.close()"></page-remission-filter>
</shell-filter-overlay>
<ui-scroll-container
[loading]="(fetching$ | async) && (items$ | async).length > 0"
(reachEnd)="loadMore()"
[deltaEnd]="150"
[itemLength]="(items$ | async).length"
class="scroll-container"
[showScrollbar]="false"
[containerHeight]="'17'"
<cdk-virtual-scroll-viewport
#scrollContainer
class="remission-list scroll-bar"
[itemSize]="368"
minBufferPx="1200"
maxBufferPx="1200"
(scrolledIndexChange)="scrolledIndexChange($event)"
>
<h1 class="text-2xl font-bold text-center">Remission</h1>
<div class="text-center">
<p class="text-xl mt-4">
Wählen Sie den Bereich aus dem <br />
Sie Artikel remittieren möchten.
</p>
<div>
<h1 class="text-2xl font-bold text-center">Remission</h1>
<div class="text-center">
<p class="text-xl mt-4">
Wählen Sie den Bereich aus dem <br />
Sie Artikel remittieren möchten.
</p>
<div class="inline-flex flex-row bg-white rounded-md mt-4">
<button
class="w-48 py-2 bg-white text-black rounded-md font-bold"
type="button"
*ngFor="let source of sources$ | async"
[class.bg-active-branch]="(selectedSource$ | async) === source"
[class.text-white]="(selectedSource$ | async) === source"
(click)="setSource(source)"
>
{{ source }}
</button>
</div>
<br />
<div class="inline-flex flex-row bg-white rounded-md mt-4">
<button
class="w-48 py-2 bg-white text-black rounded-md font-bold"
type="button"
*ngFor="let supplier of filteredSuppliers$ | async"
[class.bg-active-branch]="(selectedSupplierId$ | async) === supplier.id"
[class.text-white]="(selectedSupplierId$ | async) === supplier.id"
(click)="setSupplier(supplier)"
>
{{ supplier?.name }}
</button>
</div>
</div>
<p *ngIf="!(return$ | async)" class="text-center mt-4">
<a class="text-brand font-bold text-xl px-4 py-3" routerLink="../add-product">
Artikel hinzufügen
</a>
<button type="button" class="text-brand font-bold text-xl px-4 py-3" routerLink="../shipping-documents">
Warenbegleitscheine <span *ngIf="returnCount$ | async; let returnCount">({{ returnCount }})</span>
</button>
</p>
<h2 *ngIf="return$ | async" class="text-center font-bold text-2xl my-4">Wanne befüllen</h2>
<div class="mt-4 grid grid-flow-row gap-2" *ngIf="(selectedSource$ | async) === 'Abteilungsremission'">
<page-required-capacities [requiredCapacities]="requiredCapacities$ | async"></page-required-capacities>
</div>
<div class="relative">
<div>
<page-shared-shipping-document-details
[@shippingDocument]="showContent$ | async"
*ngIf="return$ | async; let returnDto"
[return]="returnDto"
canRemoveItems="true"
showAddToRemiHint="true"
(deleted)="shippingDocumentDeleted()"
(itemDeleted)="reloadReturn()"
></page-shared-shipping-document-details>
</div>
<div *ngIf="items$ | async; let items" [@remissionList]="showContent$ | async">
<p class="text-right mt-2 text-active-branch font-bold">{{ (hits$ | async) || 0 }} Titel</p>
<div class="mt-4 grid grid-flow-row gap-2">
<page-remission-list-item *ngFor="let item of items" [item]="item" [returnDto]="return$ | async"></page-remission-list-item>
<div class="inline-flex flex-row bg-white rounded-md mt-4">
<button
class="w-48 py-2 bg-white text-black rounded-md font-bold"
type="button"
*ngFor="let source of sources$ | async"
[class.bg-active-branch]="(selectedSource$ | async) === source"
[class.text-white]="(selectedSource$ | async) === source"
(click)="setSource(source)"
>
{{ source }}
</button>
</div>
<br />
<div class="inline-flex flex-row bg-white rounded-md mt-4">
<button
class="w-48 py-2 bg-white text-black rounded-md font-bold"
type="button"
*ngFor="let supplier of filteredSuppliers$ | async"
[class.bg-active-branch]="(selectedSupplierId$ | async) === supplier.id"
[class.text-white]="(selectedSupplierId$ | async) === supplier.id"
(click)="setSupplier(supplier)"
>
{{ supplier?.name }}
</button>
</div>
</div>
<p *ngIf="!(return$ | async)" class="text-center mt-4">
<a class="text-brand font-bold text-xl px-4 py-3" routerLink="../add-product">
Artikel hinzufügen
</a>
<button type="button" class="text-brand font-bold text-xl px-4 py-3" routerLink="../shipping-documents">
Warenbegleitscheine <span *ngIf="returnCount$ | async; let returnCount">({{ returnCount ?? 0 }})</span>
</button>
</p>
<h2 *ngIf="return$ | async" class="text-center font-bold text-2xl my-4">Wanne befüllen</h2>
<div class="mt-4 grid grid-flow-row gap-2" *ngIf="(selectedSource$ | async) === 'Abteilungsremission'">
<page-required-capacities [requiredCapacities]="requiredCapacities$ | async"></page-required-capacities>
</div>
<div class="relative">
<p class="text-right my-2 text-active-branch font-bold">{{ (hits$ | async) || 0 }} Titel</p>
<div class="remission-list-result" *cdkVirtualFor="let item of items$ | async" cdkVirtualForTrackBy="trackByItemId">
<page-remission-list-item [item]="item" [returnDto]="return$ | async"></page-remission-list-item>
</div>
<page-remission-list-item-loading *ngIf="fetching$ | async"></page-remission-list-item-loading>
</div>
</div>
<div class="h-px-100"></div>
</cdk-virtual-scroll-viewport>
<ui-spinner *ngIf="fetching$ | async; let fetching" class="text-center mt-8" [show]="fetching"></ui-spinner>
</ui-scroll-container>
<div class="actions" [ngSwitch]="showShippingDocument$ | async">
<ng-container *ngSwitchCase="false">
<div class="actions">
<ng-container>
<a
@action
*ngIf="showStartRemissionAction$ | async"
routerLink="../create"
[queryParams]="{ supplierId: selectedSupplierId$ | async }"
@@ -103,30 +89,16 @@
</a>
<a
@action
*ngIf="return$ | async"
[routerLink]="[]"
fragment="shipping-document"
(click)="reloadReturn()"
routerLink="../shipping-document"
class="flex items-center bg-white font-bold text-lg px-6 py-3 rounded-full shadow-cta"
>
Zum Warenbegleitschein
<ui-icon class="ml-2" icon="arrow_head" size="16px"></ui-icon>
</a>
</ng-container>
<ng-container *ngSwitchCase="true">
<a @action [routerLink]="[]" class="flex items-center bg-white font-bold text-lg px-6 py-3 rounded-full shadow-cta whitespace-nowrap">
<ui-icon class="mr-2" icon="arrow_head" size="16px" rotate="180deg"></ui-icon>
Warenbegleitschein befüllen
</a>
<button
@action
[routerLink]="['..', 'finish-shipping-document', firstReceiptId$ | async]"
[disabled]="finishShippingDocumentDisabled$ | async"
class="bg-brand text-white font-bold text-lg px-6 py-3 rounded-full shadow-cta whitespace-nowrap"
>
Wanne abschließen
</button>
</ng-container>
</div>
<button class="cta-scroll" *ngIf="showScrollArrow$ | async" (click)="scrollTop(0)">
<ui-icon icon="arrow" size="20px"></ui-icon>
</button>

View File

@@ -1,5 +1,17 @@
:host {
@apply flex flex-col w-full box-content relative;
max-height: calc(100vh - 284px);
height: 100vh;
}
.remission-list {
@apply m-0 p-0 mt-2 h-full;
}
.remission-list-result {
@apply list-none bg-white rounded-card mb-2;
height: 368px;
max-height: 368px;
}
.filter {
@@ -16,6 +28,10 @@
}
}
.hidden {
display: none;
}
.actions {
@apply fixed inline-grid grid-flow-col gap-7;
bottom: 7.25rem;
@@ -27,10 +43,12 @@
}
}
::ng-deep page-remission-list ui-scroll-container .cta-scroll {
bottom: 38px !important;
}
.cta-scroll {
@apply absolute shadow-cta border-none outline-none bg-white w-12 h-12 rounded-full grid items-center justify-center;
bottom: 26px;
right: 5%;
::ng-deep page-remission-list ui-scroll-container .scroll-container {
display: block !important;
ui-icon {
@apply transition-transform transform -rotate-90 text-active-branch;
}
}

View File

@@ -1,13 +1,13 @@
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, ViewChild, ViewChildren, QueryList } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ApplicationService } from '@core/application';
import { BreadcrumbService } from '@core/breadcrumb';
import { Config } from '@core/config';
import { DomainRemissionService } from '@domain/remission';
import { RemissionListItem } from '@domain/remission';
import { SupplierDTO } from '@swagger/remi';
import { Subject, Observable, combineLatest } from 'rxjs';
import { map, takeUntil, withLatestFrom } from 'rxjs/operators';
import { Subject, combineLatest, BehaviorSubject } from 'rxjs';
import { debounceTime, first, map, takeUntil, withLatestFrom } from 'rxjs/operators';
import { RemissionListItemComponent } from './remission-list-item';
import { RemissionListComponentStore } from './remission-list.component-store';
import { RemissionComponentStore } from './remission.component-store';
@@ -17,74 +17,14 @@ import { RemissionComponentStore } from './remission.component-store';
styleUrls: ['remission-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [RemissionListComponentStore, RemissionComponentStore],
animations: [
trigger('shippingDocument', [
state(
'shipping-document',
style({
opacity: 1,
transform: 'translateX(0)',
})
),
state(
'remission-list',
style({
opacity: 0,
transform: 'translateX(100%)',
position: 'absolute',
left: 0,
right: 0,
top: 0,
})
),
transition('shipping-document => remission-list', [animate('500ms ease-in-out')]),
transition('remission-list => shipping-document', [animate('500ms 500ms ease-in-out')]),
]),
trigger('remissionList', [
state(
'shipping-document',
style({
opacity: 0,
transform: 'translateX(-100%)',
display: 'none',
})
),
state(
'remission-list',
style({
opacity: 1,
transform: 'translateX(0)',
})
),
transition('shipping-document => remission-list', [animate('500ms 500ms ease-in-out')]),
transition('remission-list => shipping-document', [animate('500ms ease-in-out')]),
]),
trigger('action', [
transition(':enter', [
style({
opacity: 0,
transform: 'translateY(-100%)',
}),
animate('500ms 500ms ease-in-out'),
]),
]),
],
})
export class RemissionListComponent implements OnInit, OnDestroy {
get processId() {
return this._config.get('process.ids.remission');
}
@ViewChild('shippingDocument', { read: ElementRef })
shippingDocumentElementRef: ElementRef;
@ViewChild('remissionList', { read: ElementRef })
remissionListElementRef: ElementRef;
@ViewChildren(RemissionListItemComponent) listItems: QueryList<RemissionListItemComponent>;
@ViewChild('scrollContainer', { static: true })
scrollContainer: CdkVirtualScrollViewport;
private _onDestroy$ = new Subject();
returnCount$: Observable<number>;
get suppliers$() {
return this._remissionListStore.suppliers$;
}
@@ -125,24 +65,14 @@ export class RemissionListComponent implements OnInit, OnDestroy {
return this._remissionStore.return$;
}
get firstReceiptId$() {
return this.return$.pipe(map((returnDto) => returnDto?.receipts?.find((_) => true)?.data?.id));
get returnCount$() {
return this._remissionStore.returnCount$;
}
showShippingDocument$ = this._activatedRoute.fragment.pipe(map((fragment) => fragment === 'shipping-document'));
showContent$ = this._activatedRoute.fragment.pipe(
map((fragment) => (fragment === 'shipping-document' ? 'shipping-document' : 'remission-list'))
);
showStartRemissionAction$ = combineLatest([this.fetching$, this.hits$, this.return$]).pipe(
map(([fetching, hits, r]) => !fetching && hits > 0 && !r)
);
finishShippingDocumentDisabled$ = this.return$.pipe(
map((returnDto) => returnDto?.receipts?.find((_) => true)?.data?.items?.length === 0)
);
filteredSuppliers$ = combineLatest([this._remissionStore.uncompleted$, this.suppliers$, this.selectedSupplier$]).pipe(
map(([uncompleted, suppliers, selectedSupplier]) => {
if (!uncompleted) {
@@ -152,32 +82,38 @@ export class RemissionListComponent implements OnInit, OnDestroy {
})
);
trackByItemId = (item: RemissionListItem) => item?.dto?.id;
showScrollArrow$ = new BehaviorSubject<boolean>(false);
constructor(
private readonly _remissionListStore: RemissionListComponentStore,
private readonly _remissionStore: RemissionComponentStore,
private readonly _remissionService: DomainRemissionService,
private readonly _router: Router,
private readonly _activatedRoute: ActivatedRoute,
private readonly _breadcrumb: BreadcrumbService,
private readonly _config: Config,
private readonly _applicationService: ApplicationService
) {}
ngOnInit() {
this.removeBreadcrumbs();
this._activatedRoute.queryParams.pipe(takeUntil(this._onDestroy$)).subscribe((params) => {
const { supplier, source } = params;
this._activatedRoute.queryParams.pipe(takeUntil(this._onDestroy$), debounceTime(0)).subscribe(async (queryParams) => {
const { supplier, source } = queryParams;
if (supplier) {
this._remissionListStore.setSelectedSupplierId(+supplier);
}
if (source) {
this._remissionListStore.setSelectedSource(source);
if (source === 'Abteilungsremission') {
this._remissionListStore.loadRequiredCapacities();
}
}
const data = this._remissionListStore.getCachedData(queryParams);
if (data.items?.length !== 0) {
this._remissionListStore.setItems(data.items);
this._remissionListStore.setHits(data.hits);
this.scrollTop(Number(queryParams?.scroll_position ?? 0));
}
await this.updateBreadcrumb(queryParams);
await this.removeBreadcrumbs();
});
this._remissionListStore.filter$
@@ -188,28 +124,22 @@ export class RemissionListComponent implements OnInit, OnDestroy {
}
});
this.returnCount$ = this._remissionService
.getReturns({
returncompleted: false,
})
.pipe(map((items) => items?.length || 0));
this._activatedRoute.params.pipe(takeUntil(this._onDestroy$)).subscribe((params) => {
const id = +params.returnId;
if (!!id) {
this._remissionStore.getReturn(id);
}
});
this._remissionStore.return$.pipe(takeUntil(this._onDestroy$)).subscribe((r) => {
if (!!r) {
this._remissionListStore.setSelectedSupplierId(r.supplier.id);
} else {
this._remissionStore.getReturns(false);
}
});
this._remissionStore.getReturnCompleted.pipe(takeUntil(this._onDestroy$)).subscribe((returnDto) => {
if (!!returnDto) {
this._remissionListStore.setSelectedSupplierId(returnDto.supplier.id);
}
if (!returnDto || !!returnDto.completed) {
this._applicationService.patchProcessData(this.processId, {
this._applicationService.patchProcessData(this._remissionListStore.processId, {
active: undefined,
});
this._router.navigate(['./'], { relativeTo: this._activatedRoute.parent });
@@ -217,49 +147,76 @@ export class RemissionListComponent implements OnInit, OnDestroy {
});
}
ngOnDestroy(): void {
async ngOnDestroy() {
this._onDestroy$.next();
this._onDestroy$.complete();
if (!!this._remissionStore.return) {
const params = await this._activatedRoute.queryParams.pipe(first()).toPromise();
this._remissionListStore.cacheCurrentData(params);
await this.updateBreadcrumb(params);
}
}
setSupplier(supplier: SupplierDTO) {
this._router.navigate([], {
queryParams: {
supplier: supplier.id,
},
queryParamsHandling: 'merge',
});
async updateBreadcrumb(queryParams: Record<string, string> = {}) {
const scroll_position = this.scrollContainer.measureScrollOffset('top');
const returnId = this._activatedRoute?.snapshot?.params?.returnId;
const crumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._remissionListStore.processId, ['remission'])
.pipe(first())
.toPromise();
const params = { ...queryParams, scroll_position };
for (const crumb of crumbs) {
this._breadcrumb.patchBreadcrumb(crumb.id, {
params,
path: `/filiale/remission/${returnId ? returnId + '/' : ''}list`,
});
}
}
setSource(source: string) {
this._router.navigate([], {
queryParams: {
source,
},
queryParamsHandling: 'merge',
});
}
async scrolledIndexChange(index: number) {
const items = await this.items$.pipe(first()).toPromise();
const hits = await this.hits$.pipe(first()).toPromise();
removeBreadcrumbs() {
this._breadcrumb.removeBreadcrumbsByKeyAndTags(this.processId, ['remission', 'shipping-documents']);
this._breadcrumb.removeBreadcrumbsByKeyAndTags(this.processId, ['remission', 'add-product']);
this._breadcrumb.removeBreadcrumbsByKeyAndTags(this.processId, ['remission', 'create']);
this._breadcrumb.removeBreadcrumbsByKeyAndTags(this.processId, ['remission', 'finish-shipping-document']);
this._breadcrumb.removeBreadcrumbsByKeyAndTags(this.processId, ['remission', 'finish-remission']);
}
if (index > 0) {
this.showScrollArrow$.next(true);
} else {
this.showScrollArrow$.next(false);
}
if (items.length >= hits) {
return;
}
loadMore() {
if (this._activatedRoute.snapshot.fragment === 'shipping-document') {
return;
}
if (this._remissionListStore.hits > this._remissionListStore.items.length && !this._remissionListStore.fetching) {
if (index >= items.length - 10 && items.length - 10 > 0 && !this._remissionListStore.fetching) {
this._remissionListStore.search({});
}
}
reloadReturn() {
this._remissionStore.reloadReturn();
scrollTop(scrollPos: number) {
setTimeout(() => this.scrollContainer.scrollTo({ top: scrollPos, behavior: 'smooth' }), 0);
}
setSupplier(supplier: SupplierDTO) {
this._remissionListStore.setSupplier(supplier);
}
setSource(source: string) {
this._remissionListStore.setSource(source);
}
async removeBreadcrumbs() {
await this._breadcrumb.removeBreadcrumbsByKeyAndTags(this._remissionListStore.processId, ['remission', 'shipping-documents']);
await this._breadcrumb.removeBreadcrumbsByKeyAndTags(this._remissionListStore.processId, ['remission', 'add-product']);
await this._breadcrumb.removeBreadcrumbsByKeyAndTags(this._remissionListStore.processId, ['remission', 'create']);
await this._breadcrumb.removeBreadcrumbsByKeyAndTags(this._remissionListStore.processId, ['remission', 'finish-shipping-document']);
await this._breadcrumb.removeBreadcrumbsByKeyAndTags(this._remissionListStore.processId, ['remission', 'finish-remission']);
}
shippingDocumentDeleted() {

View File

@@ -1,6 +1,6 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { RemissionListComponent } from './remission-list.component';
import { ShellFilterOverlayModule } from '@shell/filter-overlay';
import { UiIconModule } from '@ui/icon';
@@ -8,9 +8,8 @@ import { RemissionFilterModule } from './remission-filter/remission-filter.modul
import { RemissionListItemModule } from './remission-list-item';
import { RouterModule } from '@angular/router';
import { RequiredCapacitiesModule } from '../required-capacities/required-capacities.module';
import { UiSpinnerModule } from '@ui/spinner';
import { UiScrollContainerModule } from '@ui/scroll-container';
import { SharedShippingDocumentDetailsModule } from '../shared/shipping-document-details/shipping-document-details.module';
import { RemissionListItemLoadingModule } from './remission-list-item-loading/remission-list-item-loading.module';
@NgModule({
imports: [
@@ -21,9 +20,9 @@ import { SharedShippingDocumentDetailsModule } from '../shared/shipping-document
RemissionListItemModule,
RouterModule,
RequiredCapacitiesModule,
UiSpinnerModule,
UiScrollContainerModule,
SharedShippingDocumentDetailsModule,
RemissionListItemLoadingModule,
ScrollingModule,
],
exports: [RemissionListComponent],
declarations: [RemissionListComponent],

View File

@@ -3,10 +3,11 @@ import { DomainRemissionService } from '@domain/remission';
import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { ReturnDTO } from '@swagger/remi';
import { Observable, Subject } from 'rxjs';
import { switchMap, tap, withLatestFrom } from 'rxjs/operators';
import { switchMap } from 'rxjs/operators';
export interface RemissionComponentStoreState {
return?: ReturnDTO;
returnCount?: number;
}
@Injectable()
@@ -21,6 +22,14 @@ export class RemissionComponentStore extends ComponentStore<RemissionComponentSt
return this.select((s) => s.return);
}
get returnCount() {
return this.get((s) => s.returnCount);
}
get returnCount$() {
return this.select((s) => s.returnCount);
}
get uncompleted() {
return this.get((s) => !!s?.return && !s?.return?.completed);
}
@@ -48,15 +57,25 @@ export class RemissionComponentStore extends ComponentStore<RemissionComponentSt
)
);
getReturns = this.effect((returncompleted$?: Observable<boolean>) =>
returncompleted$.pipe(
switchMap((completed) => this._domainRemissionService.getReturns({ returncompleted: completed })),
tapResponse(
(r: ReturnDTO[]) => {
this.setReturnCount(r?.length);
},
(err) => {}
)
)
);
setReturn = this.updater<ReturnDTO>((state, r) => ({
...state,
return: r,
}));
reloadReturn = this.effect(($) =>
$.pipe(
withLatestFrom(this.return$),
tap(([_, returnDto]) => this.getReturn(returnDto.id))
)
);
setReturnCount = this.updater<number>((state, returnCount) => ({
...state,
returnCount,
}));
}

View File

@@ -27,6 +27,10 @@ const routes: Routes = [
path: ':returnId/list',
component: RemissionListComponent,
},
{
path: ':returnId/shipping-document',
component: ShippingDocumentDetailsComponent,
},
{
path: 'add-product',
component: AddProductComponent,

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { DomainRemissionService } from '@domain/remission';
import { ReceiptItemDTO, ReturnDTO } from '@swagger/remi';
import { UiErrorModalComponent, UiMessageModalComponent, UiModalService } from '@ui/modal';
@@ -9,7 +9,7 @@ import { UiErrorModalComponent, UiMessageModalComponent, UiModalService } from '
styleUrls: ['shipping-document-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SharedShippingDocumentDetailsComponent implements OnInit {
export class SharedShippingDocumentDetailsComponent {
@Input()
return: ReturnDTO;
@@ -43,8 +43,6 @@ export class SharedShippingDocumentDetailsComponent implements OnInit {
constructor(private _remissionService: DomainRemissionService, private _modal: UiModalService) {}
ngOnInit() {}
async deleteReturn() {
if (this.itemsCount > 0) {
const modal = this._modal.open({

View File

@@ -1,7 +1,10 @@
<page-shared-shipping-document-details
*ngIf="return$ | async; let return"
[return]="return"
[canRemoveItems]="remissionStarted$ | async"
[showAddToRemiHint]="remissionStarted$ | async"
(deleted)="deleted($event)"
(itemDeleted)="reloadReturn()"
></page-shared-shipping-document-details>
<ng-container *ngIf="notCompleted$ | async">
@@ -20,3 +23,23 @@
</a>
</div>
</ng-container>
<div class="actions">
<ng-container *ngIf="remissionStarted$ | async">
<a
[routerLink]="['/filiale', 'remission', returnId$ | async, 'list']"
[queryParams]="scrollPosition$ | async"
class="flex items-center bg-white font-bold text-lg px-6 py-3 rounded-full shadow-cta whitespace-nowrap"
>
<ui-icon class="mr-2" icon="arrow_head" size="16px" rotate="180deg"></ui-icon>
Warenbegleitschein befüllen
</a>
<button
[routerLink]="['..', 'finish-shipping-document', firstReceiptId$ | async]"
[disabled]="finishShippingDocumentDisabled$ | async"
class="bg-brand text-white font-bold text-lg px-6 py-3 rounded-full shadow-cta whitespace-nowrap"
>
Wanne abschließen
</button>
</ng-container>
</div>

View File

@@ -1,3 +1,14 @@
:host {
@apply block;
}
.actions {
@apply fixed inline-grid grid-flow-col gap-7;
bottom: 7.25rem;
left: 50%;
transform: translateX(-50%);
button:disabled {
@apply cursor-not-allowed bg-inactive-branch;
}
}

View File

@@ -1,13 +1,20 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BreadcrumbService } from '@core/breadcrumb';
import { CacheService } from '@core/cache';
import { Config } from '@core/config';
import { DomainRemissionService } from '@domain/remission';
import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { ReturnDTO } from '@swagger/remi';
import { NEVER, Observable } from 'rxjs';
import { catchError, filter, map, shareReplay, switchMap, tap } from 'rxjs/operators';
import { catchError, filter, map, shareReplay, switchMap, tap, withLatestFrom } from 'rxjs/operators';
import { ShortReceiptNumberPipe } from '../../pipes/short-receipt-number.pipe';
export interface ShippingDocumentDetailsState {
remissionStarted?: boolean;
return: ReturnDTO;
}
@Component({
selector: 'page-shipping-document-details',
templateUrl: 'shipping-document-details.component.html',
@@ -15,8 +22,10 @@ import { ShortReceiptNumberPipe } from '../../pipes/short-receipt-number.pipe';
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ShortReceiptNumberPipe],
})
export class ShippingDocumentDetailsComponent implements OnInit {
return$: Observable<ReturnDTO>;
export class ShippingDocumentDetailsComponent extends ComponentStore<ShippingDocumentDetailsState> implements OnInit {
get processId() {
return this._config.get('process.ids.remission');
}
notCompleted$: Observable<boolean>;
@@ -24,40 +33,78 @@ export class ShippingDocumentDetailsComponent implements OnInit {
hasItems$: Observable<boolean>;
firstReceiptId$: Observable<number>;
finishShippingDocumentDisabled$: Observable<boolean>;
get remissionStarted() {
return this.get((s) => s.remissionStarted);
}
get remissionStarted$() {
return this.select((s) => s.remissionStarted).pipe(shareReplay());
}
get return() {
return this.get((s) => s.return);
}
get return$() {
return this.select((s) => s.return).pipe(shareReplay());
}
scrollPosition$ = this._breadcrumb.getBreadcrumbsByKeyAndTags$(this.processId, ['remission']).pipe(
map((crumbs) => {
const crumbParams = crumbs?.find((_) => true)?.params;
if (crumbParams && Object.keys(crumbParams)?.find((key) => key === 'scroll_position')) {
return { scroll_position: crumbParams['scroll_position'] ?? 0 };
} else {
return {};
}
})
);
constructor(
private _activatedRoute: ActivatedRoute,
private _remissionService: DomainRemissionService,
private _breadcrumb: BreadcrumbService,
private _config: Config,
private _shortReceiptNumberPipe: ShortReceiptNumberPipe,
private _router: Router
) {}
private _router: Router,
private _cache: CacheService
) {
super({ return: undefined });
}
ngOnInit() {
this.return$ = this._activatedRoute.params.pipe(
map((params) => params?.id),
filter((id) => !!id),
switchMap((id) =>
this._remissionService.getReturn(id).pipe(
catchError(() => {
this.navigateBack();
return NEVER;
})
)
),
tap((returnDto) => this.addOrUpdateBreadcrumbIfNotExists(returnDto)),
shareReplay(1)
this.removeBreadcrumbs();
this.getReturn();
this.finishShippingDocumentDisabled$ = this.return$.pipe(
map((returnDto) => returnDto?.receipts?.find((_) => true)?.data?.items?.length === 0)
);
this.notCompleted$ = this.return$.pipe(map((returnDto) => !returnDto.completed));
this.firstReceiptId$ = this.return$.pipe(map((returnDto) => returnDto?.receipts?.find((_) => true)?.data?.id));
this.notCompleted$ = this.return$.pipe(map((returnDto) => !returnDto?.completed && !this.remissionStarted));
this.returnId$ = this.return$.pipe(map((returnDto) => returnDto?.id));
this.hasItems$ = this.return$.pipe(map((returnDto) => returnDto?.receipts?.find((_) => true)?.data?.items?.length > 0));
}
removeBreadcrumbs() {
this._breadcrumb.removeBreadcrumbsByKeyAndTags(this.processId, ['remission', 'add-product']);
this._breadcrumb.removeBreadcrumbsByKeyAndTags(this.processId, ['remission', 'create']);
this._breadcrumb.removeBreadcrumbsByKeyAndTags(this.processId, ['remission', 'finish-shipping-document']);
this._breadcrumb.removeBreadcrumbsByKeyAndTags(this.processId, ['remission', 'finish-remission']);
}
navigateBack() {
this._router.navigate(['..'], { relativeTo: this._activatedRoute });
this.remissionStarted
? this._router.navigate(['../list'], { relativeTo: this._activatedRoute })
: this._router.navigate(['..'], { relativeTo: this._activatedRoute });
}
async addOrUpdateBreadcrumbIfNotExists(returnDto: ReturnDTO) {
@@ -65,9 +112,11 @@ export class ShippingDocumentDetailsComponent implements OnInit {
if (packageNumber) {
const shortPackageNumber = this._shortReceiptNumberPipe.transform(packageNumber);
this._breadcrumb.addOrUpdateBreadcrumbIfNotExists({
key: this._config.get('process.ids.remission'),
name: `#${shortPackageNumber}`,
path: `/filiale/remission/shipping-documents/${returnDto.id}`,
key: this.processId,
name: `${this.remissionStarted ? `Warenbegleitschein #${shortPackageNumber}` : `#${shortPackageNumber}`}`,
path: this.remissionStarted
? `/filiale/remission/${returnDto.id}/shipping-document`
: `/filiale/remission/shipping-documents/${returnDto.id}`,
section: 'branch',
tags: ['remission', 'shipping-documents', 'details'],
});
@@ -77,4 +126,44 @@ export class ShippingDocumentDetailsComponent implements OnInit {
deleted() {
this.navigateBack();
}
reloadReturn = this.effect(($) =>
$.pipe(
tap((_) => {
this.getReturn();
this._cache.delete({ processId: String(this.processId) });
})
)
);
getReturn = this.effect(($) =>
$.pipe(
withLatestFrom(this._activatedRoute.params),
tap(([_, params]) =>
params?.id ? this.patchState({ remissionStarted: false }) : params?.returnId ? this.patchState({ remissionStarted: true }) : ''
),
map(([_, params]) => params?.id ?? params?.returnId),
filter((id) => !!id),
switchMap((id) => this._remissionService.getReturn(id)),
tapResponse(
(r: ReturnDTO) => {
this.setReturn(r);
this.addOrUpdateBreadcrumbIfNotExists(r);
},
(err) => {
this.navigateBack();
return NEVER;
}
),
catchError(() => {
this.navigateBack();
return NEVER;
})
)
);
setReturn = this.updater<ReturnDTO>((state, r) => ({
...state,
return: r,
}));
}

View File

@@ -6,9 +6,10 @@ import { ShippingDocumentListComponent } from './shipping-document-list.componen
import { SharedShippingDocumentDetailsModule } from '../shared/shipping-document-details/shipping-document-details.module';
import { ShippingDocumentDetailsComponent } from './shipping-document-details/shipping-document-details.component';
import { RouterModule } from '@angular/router';
import { UiIconModule } from '@ui/icon';
@NgModule({
imports: [CommonModule, RemissionPipeModule, SharedShippingDocumentDetailsModule, RouterModule],
imports: [CommonModule, RemissionPipeModule, SharedShippingDocumentDetailsModule, UiIconModule, RouterModule],
exports: [],
declarations: [ShippingDocumentListComponent, ShippingDocumentListItemComponent, ShippingDocumentDetailsComponent],
providers: [],