mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Implemented return search feature with main and result components.
- ✨ **Feature**: Added return search main and result components - 🎨 **Style**: Updated styles for return search components - 🛠️ **Refactor**: Modified routing for return search functionality - 📚 **Docs**: Updated documentation references in settings
This commit is contained in:
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -37,7 +37,7 @@
|
||||
"file": ".github/testing-instructions.md"
|
||||
},
|
||||
{
|
||||
"file": "docs/text-stack.md"
|
||||
"file": "docs/tech-stack.md"
|
||||
},
|
||||
{
|
||||
"file": "docs/guidelines/code-style.md"
|
||||
@@ -60,7 +60,7 @@
|
||||
"file": ".github/testing-instructions.md"
|
||||
},
|
||||
{
|
||||
"file": "docs/text-stack.md"
|
||||
"file": "docs/tech-stack.md"
|
||||
},
|
||||
{
|
||||
"file": "docs/guidelines/code-style.md"
|
||||
@@ -77,7 +77,7 @@
|
||||
"file": ".github/review-instructions.md"
|
||||
},
|
||||
{
|
||||
"file": "docs/text-stack.md"
|
||||
"file": "docs/tech-stack.md"
|
||||
},
|
||||
{
|
||||
"file": "docs/guidelines/code-style.md"
|
||||
|
||||
@@ -163,7 +163,7 @@ const routes: Routes = [
|
||||
children: [
|
||||
{
|
||||
path: 'return',
|
||||
loadChildren: () => import('@feature/return/pages').then((m) => m.routes),
|
||||
loadChildren: () => import('@isa/oms/feature/return-search').then((m) => m.routes),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
:host {
|
||||
@apply flex flex-col pt-12 items-center;
|
||||
}
|
||||
|
||||
.lib-return-main-page__loading-spinner {
|
||||
@apply h-12 w-full flex items-center justify-center mb-12;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './lib/oms-feature-return-search/oms-feature-return-search.component';
|
||||
export * from './lib/routes';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<h1 class="isa-text-subtitle-1-regular">Rückgabe starten</h1>
|
||||
<p class="isa-text-body-1-regular text-center">
|
||||
Scannen Sie den QR-Code auf der Rechnung oder suchen Sie den Beleg
|
||||
<br />
|
||||
via Rechnungsnummer, E-Mail-Adresse oder Kundennamen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<filter-search-bar-input
|
||||
class="mt-[1.88rem] mb-[3.12rem]"
|
||||
inputKey="qs"
|
||||
(triggerSearch)="onSearch()"
|
||||
></filter-search-bar-input>
|
||||
|
||||
@if (entityPending()) {
|
||||
<div class="h-12 w-full flex items-center justify-center mb-12">
|
||||
<ui-icon-button [pending]="true" [color]="'tertiary'"></ui-icon-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex flex-row gap-4">
|
||||
@for (filterInput of filterInputs(); track filterInput.key) {
|
||||
<filter-input-menu-button [filterInput]="filterInput" (applied)="onSearch()">
|
||||
</filter-input-menu-button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply flex flex-col pt-12 items-center;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,62 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { injectActivatedProcessId } from '@isa/core/process';
|
||||
import { ReturnSearchStatus, ReturnSearchStore } from '@isa/oms/data-access';
|
||||
import {
|
||||
FilterService,
|
||||
SearchBarInputComponent,
|
||||
FilterInputMenuButtonComponent,
|
||||
} from '@isa/shared/filter';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
|
||||
@Component({
|
||||
selector: 'oms-feature-return-search-main',
|
||||
templateUrl: './return-search-main.component.html',
|
||||
styleUrls: ['./return-search-main.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SearchBarInputComponent, IconButtonComponent, FilterInputMenuButtonComponent],
|
||||
})
|
||||
export class ReturnSearchMainComponent {}
|
||||
export class ReturnSearchMainComponent {
|
||||
#route = inject(ActivatedRoute);
|
||||
#router = inject(Router);
|
||||
|
||||
private _processId = injectActivatedProcessId();
|
||||
private _filterService = inject(FilterService);
|
||||
private _returnSearchStore = inject(ReturnSearchStore);
|
||||
|
||||
private _entity = computed(() => {
|
||||
const processId = this._processId();
|
||||
if (processId) {
|
||||
return this._returnSearchStore.getEntity(processId);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
entityPending = computed(() => {
|
||||
return this._entity()?.status === ReturnSearchStatus.Pending;
|
||||
});
|
||||
|
||||
filterInputs = computed(() =>
|
||||
this._filterService.inputs().filter((input) => input.group === 'filter'),
|
||||
);
|
||||
|
||||
// TODO: Suche als Provider in FilterService auslagern (+ Cancel Search, + Fetching Status)
|
||||
async onSearch() {
|
||||
const processId = this._processId();
|
||||
if (processId) {
|
||||
await this._updateQueryParams();
|
||||
this._returnSearchStore.search({
|
||||
processId,
|
||||
params: this._filterService.toParams(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _updateQueryParams() {
|
||||
await this.#router.navigate([], {
|
||||
queryParams: this._filterService.toParams(),
|
||||
relativeTo: this.#route,
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<ui-client-row>
|
||||
<ui-client-row-content>
|
||||
<h3 class="isa-text-subtitle-1-regular">{{ name() }}</h3>
|
||||
</ui-client-row-content>
|
||||
<ui-item-row-data>
|
||||
<ui-item-row-data-row>
|
||||
<ui-item-row-data-label>Belegdatum</ui-item-row-data-label>
|
||||
<ui-item-row-data-value>
|
||||
<span class="isa-text-body-2-bold">
|
||||
{{ receiptDate() | date: 'dd.MM.yy' }}
|
||||
</span>
|
||||
</ui-item-row-data-value>
|
||||
</ui-item-row-data-row>
|
||||
<ui-item-row-data-row>
|
||||
<ui-item-row-data-label>Rechnugsnr.</ui-item-row-data-label>
|
||||
<ui-item-row-data-value>
|
||||
<span class="isa-text-body-2-bold"> {{ receiptNumber() }} </span>
|
||||
</ui-item-row-data-value>
|
||||
</ui-item-row-data-row>
|
||||
<ui-item-row-data-row>
|
||||
<ui-item-row-data-label>Vorgangs-ID</ui-item-row-data-label>
|
||||
<ui-item-row-data-value>
|
||||
<span class="isa-text-body-2-bold"> {{ orderNumber() }} </span>
|
||||
</ui-item-row-data-value>
|
||||
</ui-item-row-data-row>
|
||||
</ui-item-row-data>
|
||||
<ui-item-row-data>
|
||||
<ui-item-row-data-row>
|
||||
<ui-item-row-data-label>Email</ui-item-row-data-label>
|
||||
<ui-item-row-data-value>{{ email() }}</ui-item-row-data-value>
|
||||
</ui-item-row-data-row>
|
||||
<ui-item-row-data-row>
|
||||
<ui-item-row-data-label>Anschrift</ui-item-row-data-label>
|
||||
<ui-item-row-data-value>{{ address() }}</ui-item-row-data-value>
|
||||
</ui-item-row-data-row>
|
||||
</ui-item-row-data>
|
||||
</ui-client-row>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { ReceiptListItem } from '@isa/oms/data-access';
|
||||
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
||||
|
||||
@Component({
|
||||
selector: 'oms-feature-return-search-result-item',
|
||||
templateUrl: './return-search-result-item.component.html',
|
||||
styleUrls: ['./return-search-result-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ClientRowImports, ItemRowDataImports, DatePipe],
|
||||
})
|
||||
export class ReturnSearchResultItemComponent {
|
||||
item = input.required<ReceiptListItem>();
|
||||
|
||||
name = computed(() => {
|
||||
const firstName = this.item()?.billing?.person?.firstName;
|
||||
const lastName = this.item()?.billing?.person?.lastName;
|
||||
const buyerName = [lastName, firstName].filter((f) => !!f);
|
||||
const organisation = [this.item()?.billing?.organisation?.name].filter((f) => !!f);
|
||||
|
||||
return [organisation.join(), buyerName.join(' ')].filter((f) => !!f).join(' - ');
|
||||
});
|
||||
|
||||
receiptDate = computed(() => {
|
||||
return this.item()?.printedDate;
|
||||
});
|
||||
|
||||
receiptNumber = computed(() => {
|
||||
return this.item()?.receiptNumber;
|
||||
});
|
||||
|
||||
orderNumber = computed(() => {
|
||||
return this.item()?.orderNumber;
|
||||
});
|
||||
|
||||
email = computed(() => {
|
||||
return this.item()?.billing?.communicationDetails?.email;
|
||||
});
|
||||
|
||||
address = computed(() => {
|
||||
const address = this.item()?.billing?.address;
|
||||
return address ? [address.zipCode, address.city].join(' ') : '';
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<div class="w-full flex flex-row justify-between items-start">
|
||||
<filter-search-bar-input
|
||||
class="flex flex-row gap-4 h-12"
|
||||
[appearance]="'results'"
|
||||
inputKey="qs"
|
||||
(triggerSearch)="onSearch()"
|
||||
></filter-search-bar-input>
|
||||
|
||||
<div class="flex flex-row gap-4 items-center">
|
||||
<filter-filter-menu-button
|
||||
(applied)="onSearch()"
|
||||
[rollbackOnClose]="true"
|
||||
></filter-filter-menu-button>
|
||||
|
||||
@if (isMobileDevice()) {
|
||||
<ui-icon-button (click)="toggleOrderByToolbar.set(!toggleOrderByToolbar())">
|
||||
<ng-icon name="isaActionSort"></ng-icon>
|
||||
</ui-icon-button>
|
||||
} @else {
|
||||
<lib-return-order-by-list></lib-return-order-by-list>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isMobileDevice() && toggleOrderByToolbar()) {
|
||||
<lib-return-order-by-list class="w-full"></lib-return-order-by-list>
|
||||
}
|
||||
|
||||
<span class="text-isa-neutral-900 isa-text-body-2-regular self-start">
|
||||
{{ entityHits() }} Einträge
|
||||
</span>
|
||||
|
||||
@let items = entityItems();
|
||||
@if (items.length > 0) {
|
||||
<div class="flex flex-col gap-4 w-full items-center justify-center">
|
||||
@for (item of items; track item.id) {
|
||||
@defer (on viewport) {
|
||||
<a [routerLink]="['../', 'receipt', item.id]" class="w-full">
|
||||
<oms-feature-return-search-result-item
|
||||
#listElement
|
||||
[item]="item"
|
||||
></oms-feature-return-search-result-item>
|
||||
</a>
|
||||
} @placeholder {
|
||||
<!-- TODO: Den Spinner durch Skeleton Loader Kacheln ersetzen -->
|
||||
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
||||
<ui-icon-button [pending]="true" [color]="'tertiary'"></ui-icon-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (entityStatus() === ReturnSearchStatus.Pending) {
|
||||
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
||||
<ui-icon-button [pending]="true" [color]="'tertiary'"></ui-icon-button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (items.length === 0 && entityStatus() === ReturnSearchStatus.Pending) {
|
||||
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
||||
<ui-icon-button [pending]="true" [color]="'tertiary'"></ui-icon-button>
|
||||
</div>
|
||||
} @else if (entityStatus() !== ReturnSearchStatus.Idle) {
|
||||
<ui-empty-state [title]="emptyState().title" [description]="emptyState().description">
|
||||
</ui-empty-state>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply flex flex-col gap-4 w-full justify-start items-center;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,176 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
QueryList,
|
||||
signal,
|
||||
untracked,
|
||||
viewChildren,
|
||||
} from '@angular/core';
|
||||
import { ReturnOrderByListComponent } from '@feature/return/containers';
|
||||
import { injectActivatedProcessId } from '@isa/core/process';
|
||||
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import {
|
||||
FilterMenuButtonComponent,
|
||||
FilterService,
|
||||
SearchBarInputComponent,
|
||||
} from '@isa/shared/filter';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import { EmptyStateComponent } from '@isa/ui/empty-state';
|
||||
import { restoreScrollPosition } from '@isa/core/scroll-position';
|
||||
import { Platform } from '@angular/cdk/platform';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionSort } from '@isa/icons';
|
||||
import { ReturnSearchEntity, ReturnSearchStatus, ReturnSearchStore } from '@isa/oms/data-access';
|
||||
import { ReturnSearchResultItemComponent } from './return-search-result-item/return-search-result-item.component';
|
||||
|
||||
type EmptyState = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'oms-feature-return-search-result',
|
||||
templateUrl: './return-search-result.component.html',
|
||||
styleUrls: ['./return-search-result.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
ReturnSearchResultItemComponent,
|
||||
ReturnOrderByListComponent,
|
||||
IconButtonComponent,
|
||||
SearchBarInputComponent,
|
||||
EmptyStateComponent,
|
||||
NgIconComponent,
|
||||
FilterMenuButtonComponent,
|
||||
],
|
||||
providers: [provideIcons({ isaActionSort })],
|
||||
})
|
||||
export class ReturnSearchResultComponent {}
|
||||
export class ReturnSearchResultComponent {
|
||||
#route = inject(ActivatedRoute);
|
||||
#router = inject(Router);
|
||||
#platform = inject(Platform);
|
||||
|
||||
private _processId = injectActivatedProcessId();
|
||||
private _returnSearchStore = inject(ReturnSearchStore);
|
||||
private _filterService = inject(FilterService);
|
||||
|
||||
ReturnSearchStatus = ReturnSearchStatus;
|
||||
|
||||
filterInputs = computed(() =>
|
||||
this._filterService.inputs().filter((input) => input.group === 'filter'),
|
||||
);
|
||||
|
||||
private _entity = computed(() => {
|
||||
const processId = this._processId();
|
||||
if (processId) {
|
||||
return this._returnSearchStore.getEntity(processId);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
entityItems = computed(() => {
|
||||
return this._entity()?.items ?? [];
|
||||
});
|
||||
|
||||
entityHits = computed(() => {
|
||||
return this._entity()?.hits ?? 0;
|
||||
});
|
||||
|
||||
entityStatus = computed(() => {
|
||||
return this._entity()?.status ?? ReturnSearchStatus.Idle;
|
||||
});
|
||||
|
||||
emptyState = computed<EmptyState>(() => {
|
||||
return {
|
||||
title: 'Keine Suchergebnisse',
|
||||
description: 'Suchen Sie nach einer Rechnungsnummer oder Kundennamen.',
|
||||
};
|
||||
});
|
||||
|
||||
listElements = viewChildren<QueryList<ReturnSearchResultItemComponent>>('listElement');
|
||||
|
||||
isMobileDevice = signal(this.#platform.ANDROID || this.#platform.IOS);
|
||||
toggleOrderByToolbar = signal(false);
|
||||
|
||||
searchEffectFn = () =>
|
||||
effect(() => {
|
||||
const processId = this._processId();
|
||||
const listLength = this.listElements().length;
|
||||
|
||||
untracked(async () => {
|
||||
if (processId) {
|
||||
const entity = this._entity();
|
||||
if (entity) {
|
||||
const isPending = this.entityStatus() === ReturnSearchStatus.Pending;
|
||||
// Trigger reload search if no search request is already pending and
|
||||
// 1. List scrolled to bottom
|
||||
// 2. After Process change AND no items in entity
|
||||
if (!isPending) {
|
||||
this._reload({ processId, entity, listLength });
|
||||
}
|
||||
} else {
|
||||
// Init Search after F5 / Refresh Page / No Entity Available
|
||||
await this._initSearch();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.searchEffectFn();
|
||||
restoreScrollPosition();
|
||||
}
|
||||
|
||||
// TODO: Suche als Provider in FilterService auslagern (+ Cancel Search, + Fetching Status)
|
||||
async onSearch() {
|
||||
const processId = this._processId();
|
||||
if (processId) {
|
||||
await this._updateQueryParams();
|
||||
this._returnSearchStore.search({
|
||||
processId,
|
||||
params: this._filterService.toParams(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _updateQueryParams() {
|
||||
return await this.#router.navigate([], {
|
||||
queryParams: this._filterService.toParams(),
|
||||
relativeTo: this.#route,
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async _reload({
|
||||
processId,
|
||||
entity,
|
||||
listLength,
|
||||
}: {
|
||||
processId: number;
|
||||
entity: ReturnSearchEntity;
|
||||
listLength: number;
|
||||
}) {
|
||||
const entityItemsLength = entity?.items?.length ?? 0;
|
||||
const hits = entity?.hits ?? 0;
|
||||
// Soll reloaden wenn man am Ende der Liste in der View angekommen ist und noch nicht alle Items insgesamt geladen wurden
|
||||
if (listLength === entityItemsLength && hits !== entityItemsLength) {
|
||||
await this._updateQueryParams();
|
||||
this._returnSearchStore.reload({
|
||||
processId,
|
||||
params: this._filterService.toParams(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _initSearch() {
|
||||
const entities = this._returnSearchStore.entities();
|
||||
// For routing away from the list this entities?.length === 0 check is necessary, otherwise the init search would trigger again
|
||||
if (entities?.length === 0) {
|
||||
await this.onSearch();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { injectActivatedProcessId } from '@isa/core/process';
|
||||
import { ReturnSearchStore } from '@isa/oms/data-access';
|
||||
import { FilterService } from '@isa/shared/filter';
|
||||
import { FilterService, provideQuerySettings } from '@isa/shared/filter';
|
||||
|
||||
@Component({
|
||||
selector: 'oms-feature-return-search',
|
||||
template: `<router-outlet></router-outlet>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterOutlet],
|
||||
providers: [provideQuerySettings(() => inject(ActivatedRoute).snapshot.data['querySettings'])],
|
||||
host: {
|
||||
'[class]': '"flex flex-col gap-5 isa-desktop:gap-6 items-center overflow-x-hidden"',
|
||||
},
|
||||
@@ -54,11 +55,11 @@ export class ReturnSearchComponent {
|
||||
|
||||
if (items) {
|
||||
if (items?.length === 1) {
|
||||
return await this._navigateTo(['receipt', items[0].id.toString()]);
|
||||
return await this._navigateTo(['receipts', items[0].id.toString()]);
|
||||
}
|
||||
|
||||
if (items?.length >= 0) {
|
||||
return await this._navigateTo(['results']);
|
||||
return await this._navigateTo(['receipts']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { ActivatedRoute, Routes } from '@angular/router';
|
||||
import { Routes } from '@angular/router';
|
||||
import { ReturnSearchMainComponent } from './return-search-main/return-search-main.component';
|
||||
import { ReturnSearchComponent } from './return-search.component';
|
||||
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
|
||||
import { provideQuerySettings } from '@isa/shared/filter';
|
||||
import { inject } from '@angular/core';
|
||||
import { ReturnSearchResultComponent } from './return-search-result/return-search-result.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ReturnSearchComponent,
|
||||
resolve: { querySettings: querySettingsResolverFn },
|
||||
providers: [provideQuerySettings(() => inject(ActivatedRoute).snapshot.data['querySettings'])],
|
||||
children: [
|
||||
{ path: '', component: ReturnSearchMainComponent },
|
||||
{ path: 'returns', component: ReturnSearchMainComponent },
|
||||
{ path: 'receipts', component: ReturnSearchResultComponent },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'process',
|
||||
loadChildren: () => import('@isa/oms/feature/return-process').then((feat) => feat.routes),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -51,7 +51,9 @@ export class FilterActionsComponent {
|
||||
const inputKey = this.inputKey();
|
||||
|
||||
if (!inputKey) {
|
||||
this.filterService.reset();
|
||||
this.filterInputs().forEach((input) => {
|
||||
this.filterService.resetInput(input.key);
|
||||
});
|
||||
} else {
|
||||
this.filterService.resetInput(inputKey);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { inject, Injectable, InjectionToken, Provider } from '@angular/core';
|
||||
import { computed, inject, Injectable, InjectionToken, Provider } from '@angular/core';
|
||||
import { InputType, QuerySettingsDTO } from '../types';
|
||||
import { getState, patchState, signalState } from '@ngrx/signals';
|
||||
import { mapToFilter } from './mappings';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { FilterInput } from './schemas';
|
||||
|
||||
export const QUERY_SETTINGS = new InjectionToken<QuerySettingsDTO>('QuerySettings');
|
||||
|
||||
@@ -13,7 +15,9 @@ export function provideQuerySettings(factory: () => QuerySettingsDTO): Provider[
|
||||
export class FilterService {
|
||||
readonly settings = inject(QUERY_SETTINGS);
|
||||
|
||||
#commitdState = mapToFilter(this.settings);
|
||||
private readonly defaultState = mapToFilter(this.settings);
|
||||
|
||||
#commitdState = structuredClone(this.defaultState);
|
||||
|
||||
#state = signalState(this.#commitdState);
|
||||
|
||||
@@ -65,6 +69,58 @@ export class FilterService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the current state is the default state.
|
||||
* This computed property checks if the current state is equal to the default state.
|
||||
*/
|
||||
isDefaultFilter = computed(() => {
|
||||
const currentState = getState(this.#state);
|
||||
return isEqual(currentState, this.defaultState);
|
||||
});
|
||||
|
||||
isDefaultFilterInput(filterInput: FilterInput) {
|
||||
const currentInputState = this.#state.inputs().find((i) => i.key === filterInput.key);
|
||||
const defaultInputState = this.defaultState.inputs.find((i) => i.key === filterInput.key);
|
||||
|
||||
return isEqual(currentInputState, defaultInputState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the current state is empty.
|
||||
*/
|
||||
isEmptyFilter = computed(() => {
|
||||
const currentState = getState(this.#state);
|
||||
return currentState.inputs.every((input) => {
|
||||
if (input.type === InputType.Text) {
|
||||
return !input.value;
|
||||
}
|
||||
|
||||
if (input.type === InputType.Checkbox) {
|
||||
return !input.selected?.length;
|
||||
}
|
||||
|
||||
console.warn(`Input type not supported: ${input.type}`);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
isEmptyFilterInput(filterInput: FilterInput) {
|
||||
const currentInputState = this.#state.inputs().find((i) => i.key === filterInput.key);
|
||||
|
||||
if (currentInputState?.type === InputType.Text) {
|
||||
return !currentInputState.value;
|
||||
}
|
||||
|
||||
if (currentInputState?.type === InputType.Checkbox) {
|
||||
return !currentInputState.selected?.length;
|
||||
}
|
||||
|
||||
console.warn(`Input type not supported: ${currentInputState?.type}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts the current state to the last committed state.
|
||||
* This method restores the state by applying the previously saved committed state.
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
@let options = input().options;
|
||||
@if (inp && options) {
|
||||
<div [formGroup]="checkboxes" class="flex flex-col items-center justify-start gap-5">
|
||||
<label
|
||||
class="w-full flex items-center gap-4 cursor-pointer isa-text-body-2-regular has-[:checked]:isa-text-body-2-bold"
|
||||
>
|
||||
<label class="w-full flex items-center gap-4 cursor-pointer isa-text-body-2-bold">
|
||||
<ui-checkbox>
|
||||
<input (click)="toggleSelection()" [checked]="allChecked" type="checkbox" />
|
||||
</ui-checkbox>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<ui-icon-button cdkOverlayOrigin #trigger="cdkOverlayOrigin" (click)="toggle()">
|
||||
<button
|
||||
uiIconButton
|
||||
cdkOverlayOrigin
|
||||
#trigger="cdkOverlayOrigin"
|
||||
(click)="toggle()"
|
||||
type="button"
|
||||
[class.active]="isIconButtonActive()"
|
||||
>
|
||||
<ng-icon name="isaActionFilter"></ng-icon>
|
||||
</ui-icon-button>
|
||||
</button>
|
||||
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Overlay, OverlayModule } from '@angular/cdk/overlay';
|
||||
import { ChangeDetectionStrategy, Component, inject, input, model, output } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { FilterMenuComponent } from './filter-menu.component';
|
||||
@@ -15,7 +23,6 @@ import { FilterService } from '../../core';
|
||||
templateUrl: './filter-menu-button.component.html',
|
||||
styleUrls: ['./filter-menu-button.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [IconButtonComponent, OverlayModule, NgIconComponent, FilterMenuComponent],
|
||||
providers: [provideIcons({ isaActionFilter })],
|
||||
})
|
||||
@@ -24,6 +31,8 @@ export class FilterMenuButtonComponent {
|
||||
|
||||
#filter = inject(FilterService);
|
||||
|
||||
isIconButtonActive = computed(() => !this.#filter.isDefaultFilter());
|
||||
|
||||
/**
|
||||
* Tracks the open state of the filter menu.
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
@if (!activeInput()) {
|
||||
<button
|
||||
class="rounded-t-[1.25rem] px-6 py-5 border-b border-isa-neutral-300"
|
||||
class="rounded-t-[1.25rem] px-6 py-5 border-b border-isa-neutral-300 text-left text-isa-neutral-500 has-[.active]:text-isa-neutral-900"
|
||||
(click)="filter.clear()"
|
||||
type="button"
|
||||
>
|
||||
<span class="isa-text-body-2-bold text-isa-neutral-500"> Alle abwählen </span>
|
||||
<span class="isa-text-body-2-bold" [class.active]="!filter.isEmptyFilter()">
|
||||
Alle abwählen
|
||||
</span>
|
||||
</button>
|
||||
@for (filterInput of filterInputs(); track filterInput.key) {
|
||||
<button
|
||||
@@ -12,20 +14,29 @@
|
||||
class="px-6 py-5 border-b border-isa-neutral-300 inline-flex items-center gap-2 justify-between"
|
||||
(click)="activeInput.set(filterInput)"
|
||||
>
|
||||
<span class="text-isa-neutral-900 isa-text-body-2-regular">
|
||||
{{ filterInput.label }}
|
||||
</span>
|
||||
<div class="flex items-start gap-1">
|
||||
<span class="text-isa-neutral-900 isa-text-body-2-regular">
|
||||
{{ filterInput.label }}
|
||||
</span>
|
||||
@if (!filter.isDefaultFilterInput(filterInput)) {
|
||||
<span
|
||||
class="bg-isa-accent-red size-[0.375rem] rounded-full inline-block mt-[0.125rem]"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ng-icon class="text-isa-neutral-900" name="isaActionChevronRight" size="1.5rem"></ng-icon>
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
@let input = activeInput();
|
||||
<button
|
||||
class="rounded-t-[1.25rem] px-6 py-5 border-b border-isa-neutral-300"
|
||||
(click)="filter.rollbackInput(input!.key); activeInput.set(undefined)"
|
||||
class="flex justify-start items-center gap-2 rounded-t-[1.25rem] px-6 py-5 border-b border-isa-neutral-300 text-left text-isa-neutral-900 isa-text-body-2-regular"
|
||||
(click)="activeInput.set(undefined)"
|
||||
type="button"
|
||||
>
|
||||
<span class="isa-text-body-2-bold text-isa-neutral-500"> {{ input!.label }} </span>
|
||||
<ng-icon name="isaActionChevronLeft" size="1rem"></ng-icon>
|
||||
<span> {{ input!.label }} </span>
|
||||
</button>
|
||||
|
||||
<filter-input-renderer class="overflow-scroll" [filterInput]="input!"></filter-input-renderer>
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
[cdkConnectedOverlayOpen]="open()"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
|
||||
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
|
||||
[cdkConnectedOverlayOffsetX]="-10"
|
||||
[cdkConnectedOverlayOffsetY]="18"
|
||||
(backdropClick)="toggle()"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, input, model, output } from '@angular/core';
|
||||
import { FilterInput } from '../../core';
|
||||
import { FilterInput, FilterService } from '../../core';
|
||||
import { Overlay, OverlayModule } from '@angular/cdk/overlay';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
|
||||
@@ -20,6 +20,8 @@ import { FilterInputMenuComponent } from './input-menu.component';
|
||||
export class FilterInputMenuButtonComponent {
|
||||
scrollStrategy = inject(Overlay).scrollStrategies.block();
|
||||
|
||||
#filter = inject(FilterService);
|
||||
|
||||
/**
|
||||
* Tracks the open state of the input menu.
|
||||
*/
|
||||
@@ -50,6 +52,11 @@ export class FilterInputMenuButtonComponent {
|
||||
*/
|
||||
applied = output<void>();
|
||||
|
||||
/**
|
||||
* Determines whether to commit changes when the menu is closed.
|
||||
*/
|
||||
commitOnClose = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Subscribes to the `applied` event to automatically close the menu.
|
||||
*/
|
||||
@@ -69,6 +76,10 @@ export class FilterInputMenuButtonComponent {
|
||||
|
||||
if (open) {
|
||||
this.closed.emit();
|
||||
|
||||
if (this.commitOnClose()) {
|
||||
this.#filter.commit();
|
||||
}
|
||||
} else {
|
||||
this.opened.emit();
|
||||
}
|
||||
|
||||
@@ -57,10 +57,10 @@
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.1.8",
|
||||
"@angular-devkit/core": "^19.1.8",
|
||||
"@angular-devkit/schematics": "^19.1.8",
|
||||
"@angular/cli": "^19.1.8",
|
||||
"@angular-devkit/build-angular": "19.2.6",
|
||||
"@angular-devkit/core": "19.2.6",
|
||||
"@angular-devkit/schematics": "19.2.6",
|
||||
"@angular/cli": "19.2.6",
|
||||
"@angular/compiler-cli": "19.1.7",
|
||||
"@angular/language-service": "19.1.7",
|
||||
"@angular/pwa": "^19.2.0",
|
||||
|
||||
Reference in New Issue
Block a user