feat: enhance return process with product category selection and update dropdown functionality

This commit is contained in:
Lorenz Hilpert
2025-03-27 17:01:00 +01:00
parent 1855b1970d
commit 0c2feb96ac
18 changed files with 298 additions and 114 deletions

View File

@@ -1,7 +1,6 @@
import {
DropdownAppearance,
DropdownButtonComponent,
DropdownOptionAppearance,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { type Meta, type StoryObj, argsToTemplate, moduleMetadata } from '@storybook/angular';
@@ -10,7 +9,6 @@ type DropdownInputProps = {
value: string;
label: string;
appearance: DropdownAppearance;
optionAppearance: DropdownOptionAppearance;
};
const meta: Meta<DropdownInputProps> = {
@@ -27,10 +25,6 @@ const meta: Meta<DropdownInputProps> = {
control: 'select',
options: Object.values(DropdownAppearance),
},
optionAppearance: {
control: 'select',
options: Object.values(DropdownOptionAppearance),
},
},
render: (args) => ({
props: args,

View File

@@ -13,7 +13,7 @@ export class IDBStorageProvider implements StorageProvider {
}
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('isa-cache', 1);
const request = indexedDB.open('isa-storage', 1);
request.onerror = (event) => {
reject(event);

View File

@@ -37,8 +37,10 @@
</div>
</div>
<div uiItemRowProdcutCheckbox class="flex justify-center items-center">
<ui-checkbox appearance="bullet">
<input type="checkbox" [checked]="selected()" (click)="selected.set(!selected())" />
</ui-checkbox>
@if (selectable()) {
<ui-checkbox appearance="bullet">
<input type="checkbox" [checked]="selected()" (click)="selected.set(!selected())" />
</ui-checkbox>
}
</div>
</ui-item-row>

View File

@@ -29,4 +29,8 @@ export class ReturnDetailsOrderGroupItemComponent {
item = input.required<ReceiptItem>();
selected = model(false);
selectable = computed(() => {
return this.item()?.actions?.some((a) => a.key === 'canReturn' && Boolean(a.value));
});
}

View File

@@ -1,17 +1,14 @@
<ui-toolbar size="small" class="justify-self-stretch">
<div class="isa-text-body-2-bold text-isa-neutral-900">{{ items().length }} Artikel</div>
<div class="flex-grow"></div>
<button
type="button"
uiTextButton
color="strong"
size="small"
(click)="selectAll.emit(!allSelected())"
>
@if (allSelected()) {
Alles abwählen
} @else {
Alles auswählen
}
</button>
@if (selectableItems().length) {
<button type="button" uiTextButton color="strong" size="small" (click)="selectOrUnselectAll()">
@if (allSelected()) {
Alles abwählen
} @else {
Alles auswählen
}
</button>
}
</ui-toolbar>

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
import { ReceiptItem } from '@feature/return/services';
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
import { ReceiptItem } from '@isa/oms/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { ToolbarComponent } from '@isa/ui/toolbar';
@@ -14,7 +14,28 @@ import { ToolbarComponent } from '@isa/ui/toolbar';
export class ReturnDetailsOrderGroupComponent {
items = input.required<ReceiptItem[]>();
allSelected = input<boolean>(false);
selectedItems = model<ReceiptItem[]>([]);
selectAll = output<boolean>();
selectableItems = computed(() => {
return this.items().filter((item) =>
item.actions?.some((a) => a.key === 'canReturn' && Boolean(a.value)),
);
});
selectOrUnselectAll() {
const selectedItems = this.selectedItems();
const selectableItems = this.selectableItems();
if (selectedItems.length === selectableItems.length) {
this.selectedItems.set([]);
return;
}
this.selectedItems.set(this.selectableItems());
}
allSelected = computed(() => {
const selectedItems = this.selectedItems();
const selectableItems = this.selectableItems();
return selectedItems.length === selectableItems.length;
});
}

View File

@@ -43,16 +43,15 @@
}
<div></div>
<lib-return-details-order-group
[items]="receipt.items"
[allSelected]="receipt.items.length === selectedItems().length"
(selectAll)="selectAll($event)"
[items]="receiptItems()"
[(selectedItems)]="selectedItems"
></lib-return-details-order-group>
@for (item of receipt.items; track item.id; let last = $last) {
<lib-return-details-order-group-item
class="border-b border-solid border-isa-neutral-300 last:border-none"
[item]="item.data"
(selectedChange)="selectedChange(item.id)"
[selected]="selectedItems().includes(item.id)"
(selectedChange)="selectItemById(item.id, $event)"
[selected]="selectedItemIds().includes(item.id)"
></lib-return-details-order-group-item>
}
}

View File

@@ -51,8 +51,11 @@ export class DetailsPageComponent {
params = toSignal(this._activatedRoute.params);
// DUMMY Implementierung
selectedItems = signal<number[]>([]);
selectedItems = signal<ReceiptItem[]>([]);
selectedItemIds = computed(() => {
return this.selectedItems().map((item) => item.id);
});
showMore = signal(false);
@@ -73,6 +76,15 @@ export class DetailsPageComponent {
return this.#returnDetailsStore.entityMap()[itemId];
});
receiptItems = computed<ReceiptItem[]>(() => {
const receiptResult = this.receiptResult();
if (!receiptResult) {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return receiptResult.data?.items?.map((i) => i.data!) || [];
});
constructor() {
effect(() => {
const itemId = this.itemId();
@@ -82,27 +94,16 @@ export class DetailsPageComponent {
});
}
// DUMMY Implementierung
selectedChange(itemId: number) {
const selectedItems = this.selectedItems();
const index = selectedItems.indexOf(itemId);
if (index === -1) {
this.selectedItems.set([...selectedItems, itemId]);
} else {
this.selectedItems.set(selectedItems.filter((id) => id !== itemId));
selectItemById(id: number, selected: boolean) {
const items = this.receiptItems();
const item = items.find((i) => i.id === id);
if (!item) {
return;
}
}
// DUMMY Implementierung
selectAll(selected: boolean) {
if (selected) {
const receiptResult = this.receiptResult();
if (receiptResult) {
const allIds = receiptResult.data?.items?.map((item) => item.id) || [];
this.selectedItems.set(allIds);
}
this.selectedItems.update((items) => [...items, item]);
} else {
this.selectedItems.set([]);
this.selectedItems.update((items) => items.filter((i) => i.id !== id));
}
}
@@ -118,13 +119,11 @@ export class DetailsPageComponent {
return;
}
const items = selectedItems.reduce((acc, id) => {
const item = data.items.find((item) => item.id === id);
if (item?.data) {
acc.push(item.data);
}
return acc;
}, [] as ReceiptItem[]);
const items = this.selectedItems();
if (items.length === 0) {
return;
}
this.#returnProcessStore.startProcess({
processId,

View File

@@ -6,4 +6,5 @@ export interface ReturnProcess {
receiptId: number;
receiptItem: ReceiptItem;
answers: Record<string, unknown>;
productCategory?: string;
}

View File

@@ -184,11 +184,101 @@ export function validateNonbookQuestions(answers: Record<string, unknown>): Elig
return { state: EligibleForReturnState.NotEligible, reason: 'Invalid answers' };
}
export const andereElektronischeGeraete: ReturnProcessQuestion[] = [
{
key: ReturnProcessQuestionKey.ItemCondition,
description: 'Wie ist der Artkelzustand?',
type: ReturnProcessQuestionType.Select,
options: [
{
label: 'Originalverpackt',
value: 'ovp',
nextQuestion: ReturnProcessQuestionKey.ReturnReason,
},
{
label: 'Geöffnet / Beschädigt',
value: 'damaged',
nextQuestion: ReturnProcessQuestionKey.ItemDefective,
},
],
},
{
key: ReturnProcessQuestionKey.ReturnReason,
description: 'Warum möchtest du den Artikel zurückgeben?',
type: ReturnProcessQuestionType.Select,
options: [
{ label: 'Gefällt nicht/Wiederruf', value: 'dislike' },
{
label: 'Fehllieferung',
value: 'wrong_item',
nextQuestion: ReturnProcessQuestionKey.DeliveredItem,
},
],
},
{
key: ReturnProcessQuestionKey.ItemDefective,
description: 'Ist der Artikel defekt?',
type: ReturnProcessQuestionType.Select,
options: [
{
label: 'Ja',
value: 'yes',
},
{
label: 'Nein',
value: 'no',
},
],
},
{
key: ReturnProcessQuestionKey.DeliveredItem,
description: 'Welcher Artikel wurde geliefert?',
type: ReturnProcessQuestionType.Product,
},
];
export function validateAndereElektronischeGeraeteQuestions(
answers: Record<string, unknown>,
): EligibleForReturn {
// Check if the item is in original condition
if (answers[ReturnProcessQuestionKey.ItemCondition] === 'ovp') {
// Check if the return reason is dislike
if (answers[ReturnProcessQuestionKey.ReturnReason] === 'dislike') {
return { state: EligibleForReturnState.Eligible };
}
// Check if the return reason is wrong item
else if (answers[ReturnProcessQuestionKey.ReturnReason] === 'wrong_item') {
// Check if the delivered item is selected
if (answers[ReturnProcessQuestionKey.DeliveredItem]) {
return { state: EligibleForReturnState.Eligible };
}
// Return pending if the delivered item is not selected
else {
return { state: EligibleForReturnState.Pending, reason: 'Missing delivered item' };
}
}
}
// Check if the item is damaged
else if (answers[ReturnProcessQuestionKey.ItemCondition] === 'damaged') {
// Check if the item is defective
if (answers[ReturnProcessQuestionKey.ItemDefective] === 'yes') {
return { state: EligibleForReturnState.Eligible };
}
// Check if the item is not defective
else if (answers[ReturnProcessQuestionKey.ItemDefective] === 'no') {
return { state: EligibleForReturnState.NotEligible, reason: 'Item not defective' };
}
}
return { state: EligibleForReturnState.NotEligible, reason: 'Invalid answers' };
}
export const CategoryQuestions: Record<string, ReturnProcessQuestion[]> = {
'Buch/Kalender': bookCalendarQuestions,
'Ton-/Datenträger': tonDatentraegerQuestions,
'Nonbook': nonbookQuestions,
'default': bookCalendarQuestions,
'Spielwaren/Puzzle': nonbookQuestions,
'Sonstiges und Nonbook': nonbookQuestions,
'Andere Elektronische Geräte': andereElektronischeGeraete,
};
export const CategoryQuestionValidators: Record<
@@ -197,6 +287,7 @@ export const CategoryQuestionValidators: Record<
> = {
'Buch/Kalender': validateBookCalendarQuestions,
'Ton-/Datenträger': validateTonDatentraegerQuestions,
'Nonbook': validateNonbookQuestions,
'default': validateBookCalendarQuestions,
'Spielwaren/Puzzle': validateNonbookQuestions,
'Sonstiges und Nonbook': validateNonbookQuestions,
'Andere Elektronische Geräte': validateAndereElektronischeGeraeteQuestions,
};

View File

@@ -7,34 +7,43 @@ import {
ReturnProcessQuestionType,
} from './models';
import { CategoryQuestions, CategoryQuestionValidators } from './return-process-questions.token';
import { KeyValue } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class ReturnProcessService {
returnProcessQuestions(process: ReturnProcess): ReturnProcessQuestion[] {
const category = process.receiptItem.features?.['category'];
const defaultQuestions = CategoryQuestions['default'];
availableCategories(): KeyValue<string, string>[] {
return Object.keys(CategoryQuestions).map((key) => {
return { key, value: key };
});
}
returnProcessQuestions(process: ReturnProcess): ReturnProcessQuestion[] | undefined {
const category = process.productCategory || process.receiptItem.features?.['category'];
if (category) {
return CategoryQuestions[category] || defaultQuestions;
return CategoryQuestions[category];
}
return defaultQuestions;
return undefined;
}
returnProcessQuestionValidator(
process: ReturnProcess,
): (answers: Record<string, unknown>) => EligibleForReturn {
const category = process.receiptItem.features?.['category'];
const defaultValidator = CategoryQuestionValidators['defaultValidator'];
): ((answers: Record<string, unknown>) => EligibleForReturn) | undefined {
const category = process.productCategory || process.receiptItem.features?.['category'];
if (category) {
return CategoryQuestionValidators[category] || defaultValidator;
return CategoryQuestionValidators[category];
}
return defaultValidator;
return undefined;
}
activeReturnProcessQuestions(process: ReturnProcess): ReturnProcessQuestion[] {
activeReturnProcessQuestions(process: ReturnProcess): ReturnProcessQuestion[] | undefined {
const questions = this.returnProcessQuestions(process);
if (!questions) {
return undefined;
}
let questionKey: string | undefined = questions[0].key;
const result: ReturnProcessQuestion[] = [];
const visited = new Set<string>();
@@ -78,6 +87,10 @@ export class ReturnProcessService {
): { answered: number; total: number } | undefined {
const questions = this.returnProcessQuestions(returnProcess);
if (!questions) {
return undefined;
}
const visited = new Set<string>();
function computeLongestPath(questions: ReturnProcessQuestion[], startKey: string) {
if (visited.has(startKey)) return 0;
@@ -106,6 +119,10 @@ export class ReturnProcessService {
const activeQuestions = this.activeReturnProcessQuestions(returnProcess);
if (!activeQuestions) {
return undefined;
}
const answered = activeQuestions.reduce((acc, q) => {
if (q.key in returnProcess.answers) {
return acc + 1;
@@ -120,12 +137,21 @@ export class ReturnProcessService {
eligibleForReturn(returnProcess: ReturnProcess): EligibleForReturn {
const questions = this.activeReturnProcessQuestions(returnProcess);
if (!questions) {
return { state: EligibleForReturnState.Pending };
}
const everyQuestionAnswered = questions.every((q) => q.key in returnProcess.answers);
if (!everyQuestionAnswered) {
return { state: EligibleForReturnState.Pending, reason: 'Not all questions answered' };
}
const validator = this.returnProcessQuestionValidator(returnProcess);
if (!validator) {
return { state: EligibleForReturnState.Pending, reason: 'No validator found' };
}
return validator(returnProcess.answers);
}
}

View File

@@ -44,6 +44,14 @@ export const ReturnProcessStore = signalStore(
store.storeState();
}
},
setProductCategory: (id: number, category: string) => {
const entity = store.entityMap()[id];
if (entity) {
patchState(store, updateEntity({ id: entity.id, changes: { productCategory: category } }));
store.storeState();
}
},
})),
withMethods((store) => ({
startProcess: (params: StartProcess) => {

View File

@@ -1,3 +1,20 @@
@if (showProductCategoryDropdown()) {
<div
class="flex flex-row gap-2 w-full justify-between items-center border-b border-isa-neutral-300 pb-6 last:border-b-0"
>
<div class="isa-text-body-1-regular">Um welches Produkt handelt es sich?</div>
<ui-dropdown
label="Produktart auswählen"
[value]="productCategoryDropdown()"
(valueChange)="setProductCategory($event)"
>
@for (kv of availableCategories(); track kv.key) {
<ui-dropdown-option [value]="kv.key">{{ kv.value }}</ui-dropdown-option>
}
</ui-dropdown>
</div>
}
@for (question of questions(); track question.key; let last = $last) {
<div class="item-content" [class.with-border-bottom]="!last">
@switch (question.type) {

View File

@@ -3,13 +3,20 @@ import { ChangeDetectionStrategy, Component, computed, inject, input } from '@an
import { ReturnProcess, ReturnProcessService, ReturnProcessStore } from '@isa/oms/data-access';
import { ReturnProcessSelectQuestionComponent } from './return-process-select-question/return-process-select-question.component';
import { ReturnProcessProductQuestionComponent } from './return-process-product-question/return-process-product-question.component';
import { DropdownButtonComponent, DropdownOptionComponent } from '@isa/ui/input-controls';
@Component({
selector: 'lib-return-process-questions',
templateUrl: './return-process-questions.component.html',
styleUrls: ['./return-process-questions.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [JsonPipe, ReturnProcessSelectQuestionComponent, ReturnProcessProductQuestionComponent],
imports: [
JsonPipe,
ReturnProcessSelectQuestionComponent,
ReturnProcessProductQuestionComponent,
DropdownButtonComponent,
DropdownOptionComponent,
],
})
export class ReturnProcessQuestionsComponent {
#returnProcessStore = inject(ReturnProcessStore);
@@ -29,4 +36,38 @@ export class ReturnProcessQuestionsComponent {
}
return this.#returnProcessSerivce.activeReturnProcessQuestions(returnProcess);
});
availableCategories = computed(() => {
return this.#returnProcessSerivce.availableCategories();
});
setProductCategory(category: string) {
this.#returnProcessStore.setProductCategory(this.returnProcessId(), category);
}
productCategoryDropdown = computed(() => {
const returnProcess = this.returnProcess();
if (!returnProcess) {
return undefined;
}
return returnProcess.productCategory || returnProcess.receiptItem.features?.['category'];
});
showProductCategoryDropdown = computed(() => {
const returnProcess = this.returnProcess();
if (!returnProcess) {
return false;
}
const selectedCategory = returnProcess.productCategory;
if (selectedCategory) {
return true;
}
const questions = this.questions();
return !questions;
});
}

View File

@@ -6,9 +6,10 @@
[cdkConnectedOverlayOrigin]="cdkOverlayOrigin"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayOffsetY]="12"
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
(detach)="isOpen.set(false)"
>
<ul [class]="['ui-dorpdown__options', optionAppearanceClass()]" role="listbox">
<ul [class]="['ui-dorpdown__options']" role="listbox">
<ng-content></ng-content>
</ul>
</ng-template>

View File

@@ -35,6 +35,7 @@
border-radius: 1.25rem;
background: var(--Neutral-White, #fff);
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.15);
width: 100%;
.ui-dropdown-option {
display: flex;
@@ -48,6 +49,7 @@
word-wrap: none;
white-space: nowrap;
cursor: pointer;
width: 100%;
@apply isa-text-body-2-bold;
@@ -61,16 +63,4 @@
@apply text-isa-accent-blue;
}
}
&.ui-dropdown-option__options-text {
.ui-dropdown-option {
align-items: start;
}
}
&.ui-dropdown-option__options-number {
.ui-dropdown-option {
align-items: center;
}
}
}

View File

@@ -16,12 +16,10 @@ import {
import { ControlValueAccessor } from '@angular/forms';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
import { SelectionModel } from '@angular/cdk/collections';
import { ActiveDescendantKeyManager, Highlightable } from '@angular/cdk/a11y';
import { toSignal } from '@angular/core/rxjs-interop';
import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay';
import { isEqual } from 'lodash';
import { DropdownAppearance, DropdownOptionAppearance } from './dropdown.types';
import { DropdownAppearance } from './dropdown.types';
@Component({
selector: 'ui-dropdown-option',
@@ -94,15 +92,16 @@ export class DropdownOptionComponent<T> implements Highlightable {
})
export class DropdownButtonComponent<T> implements ControlValueAccessor, AfterViewInit {
readonly init = signal(false);
private elementRef = inject(ElementRef);
get overlayMinWidth() {
return this.elementRef.nativeElement.offsetWidth;
}
appearance = input<DropdownAppearance>(DropdownAppearance.Button);
appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`);
optionAppearance = input<DropdownOptionAppearance>(DropdownOptionAppearance.Text);
optionAppearanceClass = computed(() => `ui-dropdown-option__options-${this.optionAppearance()}`);
id = input<string>();
value = model<T>();
@@ -117,13 +116,13 @@ export class DropdownButtonComponent<T> implements ControlValueAccessor, AfterVi
cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true });
private selectionModel = new SelectionModel<DropdownOptionComponent<T>>(false);
selectedOption = computed(() => {
const options = this.options();
if (!options) {
return undefined;
}
selectionChanged = toSignal(this.selectionModel.changed);
selected = computed(() => {
this.selectionChanged();
return this.selectionModel.selected[0];
return options.find((option) => option.value() === this.value());
});
private keyManger?: ActiveDescendantKeyManager<DropdownOptionComponent<T>>;
@@ -139,11 +138,13 @@ export class DropdownButtonComponent<T> implements ControlValueAccessor, AfterVi
isOpenIcon = computed(() => (this.isOpen() ? 'isaActionChevronUp' : 'isaActionChevronDown'));
viewLabel = computed(() => {
if (!this.selected()) {
return this.label();
const selectedOption = this.selectedOption();
if (!selectedOption) {
return this.label() ?? this.value();
}
return this.selected().getLabel() ?? this.value();
return selectedOption.getLabel();
});
constructor() {
@@ -159,7 +160,7 @@ export class DropdownButtonComponent<T> implements ControlValueAccessor, AfterVi
}
open() {
const selected = this.selected();
const selected = this.selectedOption();
if (selected) {
this.keyManger?.setActiveItem(selected);
} else {
@@ -197,8 +198,8 @@ export class DropdownButtonComponent<T> implements ControlValueAccessor, AfterVi
}
select(option: DropdownOptionComponent<T>, options: { emit: boolean } = { emit: true }) {
this.selectionModel.select(option);
this.value.set(option.value());
if (options.emit) {
this.onChange?.(option.value());
}

View File

@@ -3,11 +3,3 @@ export const DropdownAppearance = {
} as const;
export type DropdownAppearance = (typeof DropdownAppearance)[keyof typeof DropdownAppearance];
export const DropdownOptionAppearance = {
Text: 'text',
Number: 'number',
} as const;
export type DropdownOptionAppearance =
(typeof DropdownOptionAppearance)[keyof typeof DropdownOptionAppearance];