Merged PR 1942: feat(remission-list, search-item-to-remit-dialog): simplify dialog flow by re...

feat(remission-list, search-item-to-remit-dialog): simplify dialog flow by removing conditional views

Refactor the search item to remit dialog to use a dedicated quantity and reason
dialog instead of conditional views within the main dialog. This change improves
user experience by providing clearer navigation and better separation of concerns.

Key changes:
- Remove item signal and conditional template logic from SearchItemToRemitDialogComponent
- Create new SelectRemiQuantityAndReasonDialogComponent for quantity/reason selection
- Update SearchItemToRemitComponent to open quantity dialog instead of setting item state
- Simplify dialog data interface by removing isDepartment property
- Improve stock filtering logic to show only items with available stock
- Fix import path for QuantityAndReason interface

This refactor eliminates complex state management within the dialog and provides
a more intuitive user flow with dedicated dialogs for each step.

Ref: #5326
This commit is contained in:
Nino Righi
2025-09-10 14:18:17 +00:00
committed by Lorenz Hilpert
parent 8cf80a60a0
commit 0ca58fe1bf
12 changed files with 359 additions and 356 deletions

View File

@@ -418,7 +418,6 @@ export class RemissionListComponent {
this.searchItemToRemitDialog({
data: {
searchTerm,
isDepartment: this.isDepartment(),
},
}).closed.subscribe(async (result) => {
if (result) {

View File

@@ -15,7 +15,7 @@ import {
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { QuantityAndReason } from './select-remi-quantity-and-reason.component';
import { QuantityAndReason } from './select-remi-quantity-and-reason-dialog.component';
import { ReturnValue } from '@isa/common/data-access';
import { provideIcons } from '@ng-icons/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';

View File

@@ -1,18 +1,14 @@
@if (item()) {
<remi-select-remi-quantity-and-reason></remi-select-remi-quantity-and-reason>
} @else {
<button
class="absolute top-4 right-[1.33rem]"
type="button"
uiTextButton
size="small"
color="subtle"
(click)="close(undefined)"
tabindex="-1"
data-what="button"
data-which="close-dialog"
>
Schließen
</button>
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>
}
<button
class="absolute top-4 right-[1.33rem]"
type="button"
uiTextButton
size="small"
color="subtle"
(click)="close(undefined)"
tabindex="-1"
data-what="button"
data-which="close-dialog"
>
Schließen
</button>
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>

View File

@@ -1,33 +1,23 @@
import {
ChangeDetectionStrategy,
Component,
effect,
isSignal,
linkedSignal,
signal,
Signal,
} from '@angular/core';
import { DialogContentDirective, NumberInputValidation } from '@isa/ui/dialog';
import { Item } from '@isa/catalogue/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionSearch } from '@isa/icons';
import { SearchItemToRemitListComponent } from './search-item-to-remit-list.component';
import { SelectRemiQuantityAndReasonComponent } from './select-remi-quantity-and-reason.component';
import { Validators } from '@angular/forms';
import { ReturnSuggestion, ReturnItem } from '@isa/remission/data-access';
import { ReturnItem } from '@isa/remission/data-access';
export type SearchItemToRemitDialogData = {
searchTerm: string | Signal<string>;
isDepartment: boolean;
};
export type SearchItemToRemitDialogResult =
SearchItemToRemitDialogData extends { isDepartment: infer D }
? D extends true
? ReturnSuggestion
: ReturnItem
: never;
// #5273, #4768 Fix - Nur ReturnItems sind zugelassen und dürfen zur Pflichtremission hinzugefügt werden
export type SearchItemToRemitDialogResult = ReturnItem;
@Component({
selector: 'remi-search-item-to-remit-dialog',
@@ -35,11 +25,7 @@ export type SearchItemToRemitDialogResult =
styleUrls: ['./search-item-to-remit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
imports: [TextButtonComponent, SearchItemToRemitListComponent],
providers: [provideIcons({ isaActionSearch })],
})
export class SearchItemToRemitDialogComponent extends DialogContentDirective<
@@ -51,35 +37,4 @@ export class SearchItemToRemitDialogComponent extends DialogContentDirective<
? this.data.searchTerm()
: this.data.searchTerm,
);
item = signal<Item | undefined>(undefined);
itemEffect = effect(() => {
const item = this.item();
this.dialogRef.updateSize(item ? '36rem' : 'auto');
if (item) {
this.dialog.title.set(`Dieser Artikel steht nicht auf der Remi Liste`);
} else {
this.dialog.title.set(undefined);
}
});
quantityValidators: NumberInputValidation[] = [
{
errorKey: 'required',
inputValidator: Validators.required,
errorText: 'Bitte geben Sie eine Menge an.',
},
{
errorKey: 'min',
inputValidator: Validators.min(1),
errorText: 'Die Menge muss mindestens 1 sein.',
},
{
errorKey: 'max',
inputValidator: Validators.max(1000),
errorText: 'Die Menge darf höchstens 1000 sein.',
},
];
}

View File

@@ -14,7 +14,7 @@
name="isaActionSearch"
color="brand"
(click)="triggerSearch()"
[pending]="searchResource.isLoading()"
[pending]="searchResource.isLoading() || inStockResource.isLoading()"
data-what="button"
data-which="search-submit"
></ui-icon-button>
@@ -34,24 +34,23 @@
<ng-icon size="1.5rem" name="isaOtherInfo"></ng-icon>
</button>
</p>
<div class="overflow-y-auto">
<div class="overflow-y-auto overflow-x-hidden">
@if (searchResource.value()?.result; as items) {
@for (item of items; track item.id) {
@for (item of availableSearchResults(); track item.id) {
@defer {
@let inStock = getAvailableStockForItem(item);
@if (inStock > 0) {
<remi-search-item-to-remit
[item]="item"
[inStock]="inStock"
data-what="list-item"
data-which="search-result"
[attr.data-item-id]="item.id"
></remi-search-item-to-remit>
}
<remi-search-item-to-remit
[item]="item"
[inStock]="getAvailableStockForItem(item)"
data-what="list-item"
data-which="search-result"
[attr.data-item-id]="item.id"
></remi-search-item-to-remit>
}
}
}
@if (!hasItems() && !searchResource.isLoading()) {
@if (
!hasItems() && !searchResource.isLoading() && !inStockResource.isLoading()
) {
<ui-empty-state
class="w-full justify-self-center"
title="Keine Suchergebnisse"

View File

@@ -57,6 +57,14 @@ export class SearchItemToRemitListComponent implements OnInit {
searchParams = signal<SearchByTermInput | undefined>(undefined);
availableSearchResults = computed(() => {
return (
this.searchResource.value()?.result?.filter((item) => {
return this.getAvailableStockForItem(item) > 0;
}) ?? []
);
});
inStockResource = createInStockResource(() => {
return {
itemIds:
@@ -69,7 +77,7 @@ export class SearchItemToRemitListComponent implements OnInit {
inStockResponseValue = computed(() => this.inStockResource.value());
hasItems = computed(() => {
return (this.searchResource.value()?.result?.length ?? 0) > 0;
return (this.availableSearchResults()?.length ?? 0) > 0;
});
stockInfoMap = computed(() => {

View File

@@ -18,7 +18,7 @@
type="button"
uiTextButton
color="strong"
(click)="host.item.set(item())"
(click)="openQuantityAndReasonDialog()"
>
Remimenge auswählen
</button>

View File

@@ -10,6 +10,9 @@ import { ProductInfoComponent } from '@isa/remission/shared/product';
import { TextButtonComponent } from '@isa/ui/buttons';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { injectDialog } from '@isa/ui/dialog';
import { SelectRemiQuantityAndReasonDialogComponent } from './select-remi-quantity-and-reason-dialog.component';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'remi-search-item-to-remit',
@@ -20,6 +23,9 @@ import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.
})
export class SearchItemToRemitComponent {
host = inject(SearchItemToRemitDialogComponent);
quantityAndReasonDialog = injectDialog(
SelectRemiQuantityAndReasonDialogComponent,
);
item = input.required<Item>();
inStock = input.required<number>();
@@ -29,4 +35,22 @@ export class SearchItemToRemitComponent {
productInfoOrientation = computed(() => {
return this.desktopBreakpoint() ? 'vertical' : 'horizontal';
});
async openQuantityAndReasonDialog() {
if (this.item()) {
const dialogRef = this.quantityAndReasonDialog({
title: 'Dieser Artikel steht nicht auf der Remi Liste',
data: {
item: this.item(),
inStock: this.inStock(),
},
width: '36rem',
});
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult) {
this.host.close(dialogResult);
}
}
}
}

View File

@@ -1,84 +1,94 @@
<p class="text-isa-neutral-600 isa-text-body-1-regular">
Wie viele Exemplare können remittiert werden?
</p>
<div class="flex flex-col gap-4">
@for (
quantityAndReason of quantitiesAndResons();
track $index;
let i = $index
) {
<div class="flex items-center gap-1">
<remi-quantity-and-reason-item
[position]="$index + 1"
[quantityAndReason]="quantityAndReason"
(quantityAndReasonChange)="setQuantityAndReason($index, $event)"
class="flex-1"
data-what="component"
data-which="quantity-reason-item"
[attr.data-position]="$index + 1"
></remi-quantity-and-reason-item>
@if (i > 0) {
<ui-icon-button
type="button"
(click)="removeQuantityReasonItem($index)"
data-what="button"
data-which="remove-quantity"
[attr.data-position]="$index + 1"
name="isaActionClose"
color="neutral"
></ui-icon-button>
}
</div>
}
</div>
<div>
<button
type="button"
class="flex items-center gap-2 -ml-5"
uiTextButton
color="strong"
(click)="addQuantityReasonItem()"
data-what="button"
data-which="add-quantity"
>
<ng-icon name="isaActionPlus" size="1.5rem"></ng-icon>
<div>Menge hinzufügen</div>
</button>
</div>
<div class="text-isa-accent-red isa-text-body-1-regular">
<span>
@if (canReturnErrors(); as errors) {
@for (error of errors; track $index) {
{{ error }}
}
}
</span>
</div>
<div class="grid grid-cols-2 items-center gap-2">
<button
type="button"
color="secondary"
size="large"
uiButton
(click)="host.item.set(undefined)"
data-what="button"
data-which="back"
>
Zurück
</button>
<button
type="button"
color="primary"
size="large"
uiButton
[pending]="canAddToRemiListResource.isLoading()"
[disabled]="canAddToRemiListResource.isLoading() || canReturn() === false"
(click)="addToRemiList()"
data-what="button"
data-which="save-remission"
>
Speichern
</button>
</div>
<remi-product-info
[item]="{
product: data.item.product,
retailPrice: data.item.catalogAvailability.price,
}"
></remi-product-info>
<div class="text-isa-neutral-900 flex flex-row items-center justify-end gap-8">
<span class="isa-text-body-2-regular">Aktueller Bestand</span>
<span class="isa-text-body-2-bold">{{ data.inStock }}x</span>
</div>
<p class="text-isa-neutral-600 isa-text-body-1-regular">
Wie viele Exemplare können remittiert werden?
</p>
<div class="flex flex-col gap-4">
@for (
quantityAndReason of quantitiesAndResons();
track $index;
let i = $index
) {
<div class="flex items-center gap-1">
<remi-quantity-and-reason-item
[position]="$index + 1"
[quantityAndReason]="quantityAndReason"
(quantityAndReasonChange)="setQuantityAndReason($index, $event)"
class="flex-1"
data-what="component"
data-which="quantity-reason-item"
[attr.data-position]="$index + 1"
></remi-quantity-and-reason-item>
@if (i > 0) {
<ui-icon-button
type="button"
(click)="removeQuantityReasonItem($index)"
data-what="button"
data-which="remove-quantity"
[attr.data-position]="$index + 1"
name="isaActionClose"
color="neutral"
></ui-icon-button>
}
</div>
}
</div>
<div>
<button
type="button"
class="flex items-center gap-2 -ml-5"
uiTextButton
color="strong"
(click)="addQuantityReasonItem()"
data-what="button"
data-which="add-quantity"
>
<ng-icon name="isaActionPlus" size="1.5rem"></ng-icon>
<div>Menge hinzufügen</div>
</button>
</div>
<div class="text-isa-accent-red isa-text-body-1-regular">
<span>
@if (canReturnErrors(); as errors) {
@for (error of errors; track $index) {
{{ error }}
}
}
</span>
</div>
<div class="grid grid-cols-2 items-center gap-2">
<button
type="button"
color="secondary"
size="large"
uiButton
(click)="close(undefined)"
data-what="button"
data-which="back"
>
Zurück
</button>
<button
type="button"
color="primary"
size="large"
uiButton
[pending]="canAddToRemiListResource.isLoading()"
[disabled]="canAddToRemiListResource.isLoading() || canReturn() === false"
(click)="addToRemiList()"
data-what="button"
data-which="save-remission"
>
Speichern
</button>
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-flow-row gap-6 h-full;
}

View File

@@ -1,184 +1,196 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
model,
resource,
} from '@angular/core';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { QuantityAndReasonItemComponent } from './quantity-and-reason-item.component';
import {
ButtonComponent,
TextButtonComponent,
IconButtonComponent,
} from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionClose } from '@isa/icons';
import {
RemissionSearchService,
RemissionStore,
ReturnItem,
ReturnSuggestion,
} from '@isa/remission/data-access';
import { injectFeedbackDialog } from '@isa/ui/dialog';
import { BatchResponseArgs } from '@isa/common/data-access';
export interface QuantityAndReason {
quantity: number;
reason: string;
}
@Component({
selector: 'remi-select-remi-quantity-and-reason',
templateUrl: './select-remi-quantity-and-reason.component.html',
styleUrls: ['./select-remi-quantity-and-reason.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
QuantityAndReasonItemComponent,
TextButtonComponent,
NgIcon,
ButtonComponent,
IconButtonComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionClose })],
})
export class SelectRemiQuantityAndReasonComponent {
#remiService = inject(RemissionSearchService);
#remiStore = inject(RemissionStore);
#feedbackDialog = injectFeedbackDialog();
host = inject(SearchItemToRemitDialogComponent);
initialItem: QuantityAndReason = { quantity: 0, reason: '' };
quantitiesAndResons = model<QuantityAndReason[]>([this.initialItem]);
addQuantityReasonItem(): void {
this.quantitiesAndResons.update((items) => [...items, this.initialItem]);
}
removeQuantityReasonItem(position: number): void {
const currentItems = this.quantitiesAndResons();
if (currentItems.length > 1) {
this.quantitiesAndResons.update((items) =>
items.filter((_, index) => index !== position),
);
}
}
setQuantityAndReason(position: number, qar: QuantityAndReason): void {
this.quantitiesAndResons.update((items) => {
const newItems = [...items];
newItems[position] = qar;
return newItems;
});
}
params = computed(() => {
const items = this.quantitiesAndResons();
const item = this.host.item();
if (!item) {
return [];
}
return items.map((qar) => ({
item,
quantity: qar.quantity,
reason: qar.reason,
}));
});
canAddToRemiListResource = resource({
params: this.params,
loader: async ({ params, abortSignal }) => {
if (
!this.host.item() ||
params.some((p) => !p.reason) ||
params.some((p) => !p.quantity)
) {
return undefined;
}
const maxQuantityErrors = params.filter((p) => !(p.quantity <= 999));
if (maxQuantityErrors.length > 0) {
const errRes: BatchResponseArgs<ReturnItem> = {
completed: false,
error: true,
total: maxQuantityErrors.length,
invalidProperties: {
quantity: 'Die Menge darf maximal 999 sein.',
},
};
return errRes;
}
return this.#remiService.canAddItemToRemiList(params, abortSignal);
},
});
canReturn = computed(() => {
const results = this.canAddToRemiListResource.value();
if (!results) {
return false;
}
if (results.failed && results.failed.length > 0) {
return false;
}
if (
results.successful &&
results.successful.length === this.quantitiesAndResons().length
) {
return true;
}
return false;
});
canReturnErrors = computed(() => {
const results = this.canAddToRemiListResource.value();
if (results?.invalidProperties) {
return Object.values(results.invalidProperties);
}
if (!results?.failed) {
return [];
}
return results.failed.map((item) =>
item.invalidProperties
? Object.values(item.invalidProperties).join(', ')
: [],
) as string[];
});
async addToRemiList() {
const canAddValue = this.canAddToRemiListResource.value();
if (!canAddValue) {
return;
}
if (canAddValue.failed?.length) {
return;
}
// #5273, #4768 Fix - Items dürfen nur zur Pflichtremission hinzugefügt werden
const result: Array<ReturnItem> = await this.#remiService.addToList(
this.params(),
);
this.#feedbackDialog({
data: {
message: this.#remiStore.remissionStarted()
? 'Wurde zum Warenbegleitschein hinzugefügt'
: 'Wurde zur Remi Liste hinzugefügt',
},
});
this.host.close(result);
}
}
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
model,
resource,
} from '@angular/core';
import { QuantityAndReasonItemComponent } from './quantity-and-reason-item.component';
import {
ButtonComponent,
TextButtonComponent,
IconButtonComponent,
} from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionClose } from '@isa/icons';
import {
RemissionSearchService,
RemissionStore,
ReturnItem,
} from '@isa/remission/data-access';
import { DialogContentDirective, injectFeedbackDialog } from '@isa/ui/dialog';
import { BatchResponseArgs } from '@isa/common/data-access';
import { Item } from '@isa/catalogue/data-access';
import { ProductInfoComponent } from '@isa/remission/shared/product';
export type SelectRemiQuantityAndReasonDialogData = {
item: Item;
inStock: number;
};
export type SelectRemiQuantityAndReasonDialogResult =
| undefined
| Array<ReturnItem>;
export interface QuantityAndReason {
quantity: number;
reason: string;
}
@Component({
selector: 'remi-select-remi-quantity-and-reason-dialog',
templateUrl: './select-remi-quantity-and-reason-dialog.component.html',
styleUrls: ['./select-remi-quantity-and-reason-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
QuantityAndReasonItemComponent,
TextButtonComponent,
NgIcon,
ButtonComponent,
IconButtonComponent,
ProductInfoComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionClose })],
})
export class SelectRemiQuantityAndReasonDialogComponent extends DialogContentDirective<
SelectRemiQuantityAndReasonDialogData,
SelectRemiQuantityAndReasonDialogResult
> {
#remiService = inject(RemissionSearchService);
#remiStore = inject(RemissionStore);
#feedbackDialog = injectFeedbackDialog();
initialItem: QuantityAndReason = { quantity: 0, reason: '' };
quantitiesAndResons = model<QuantityAndReason[]>([this.initialItem]);
addQuantityReasonItem(): void {
this.quantitiesAndResons.update((items) => [...items, this.initialItem]);
}
removeQuantityReasonItem(position: number): void {
const currentItems = this.quantitiesAndResons();
if (currentItems.length > 1) {
this.quantitiesAndResons.update((items) =>
items.filter((_, index) => index !== position),
);
}
}
setQuantityAndReason(position: number, qar: QuantityAndReason): void {
this.quantitiesAndResons.update((items) => {
const newItems = [...items];
newItems[position] = qar;
return newItems;
});
}
params = computed(() => {
const items = this.quantitiesAndResons();
const item = this.data.item;
if (!item) {
return [];
}
return items.map((qar) => ({
item,
quantity: qar.quantity,
reason: qar.reason,
}));
});
canAddToRemiListResource = resource({
params: this.params,
loader: async ({ params, abortSignal }) => {
if (
!this.data.item ||
params.some((p) => !p.reason) ||
params.some((p) => !p.quantity)
) {
return undefined;
}
const maxQuantityErrors = params.filter((p) => !(p.quantity <= 999));
if (maxQuantityErrors.length > 0) {
const errRes: BatchResponseArgs<ReturnItem> = {
completed: false,
error: true,
total: maxQuantityErrors.length,
invalidProperties: {
quantity: 'Die Menge darf maximal 999 sein.',
},
};
return errRes;
}
return this.#remiService.canAddItemToRemiList(params, abortSignal);
},
});
canReturn = computed(() => {
const results = this.canAddToRemiListResource.value();
if (!results) {
return false;
}
if (results.failed && results.failed.length > 0) {
return false;
}
if (
results.successful &&
results.successful.length === this.quantitiesAndResons().length
) {
return true;
}
return false;
});
canReturnErrors = computed(() => {
const results = this.canAddToRemiListResource.value();
if (results?.invalidProperties) {
return Object.values(results.invalidProperties);
}
if (!results?.failed) {
return [];
}
return results.failed.map((item) =>
item.invalidProperties
? Object.values(item.invalidProperties).join(', ')
: [],
) as string[];
});
async addToRemiList() {
const canAddValue = this.canAddToRemiListResource.value();
if (!canAddValue) {
return;
}
if (canAddValue.failed?.length) {
return;
}
// #5273, #4768 Fix - Items dürfen nur zur Pflichtremission hinzugefügt werden
const result: Array<ReturnItem> = await this.#remiService.addToList(
this.params(),
);
this.#feedbackDialog({
data: {
message: this.#remiStore.remissionStarted()
? 'Wurde zum Warenbegleitschein hinzugefügt'
: 'Wurde zur Remi Liste hinzugefügt',
},
});
this.close(result);
}
}

View File

@@ -1,3 +0,0 @@
:host {
@apply grid grid-flow-row gap-6;
}