Enhance return process with checklist questions and related components.

-  **Feature**: Added checklist question type for return process
-  **Feature**: Implemented checklist question component
- 🛠️ **Refactor**: Updated return process service to handle checklist answers
- 📚 **Docs**: Added schemas for checklist answers and return details
- 🧪 **Test**: Updated tests for new checklist functionality
This commit is contained in:
Lorenz Hilpert
2025-04-14 16:13:44 +02:00
parent 621a8a5dc7
commit e65085439e
26 changed files with 408 additions and 81 deletions

View File

@@ -6,6 +6,7 @@ export const ReturnProcessQuestionKey = {
DisplayCondition: 'display_condition',
DevicePower: 'device_power',
PackageComplete: 'package_complete',
PackageIncomplete: 'package_incomplete',
CaseCondition: 'case_condition',
UsbPort: 'usb_port',
} as const;

View File

@@ -1,6 +1,7 @@
export const ReturnProcessQuestionType = {
Select: 'select',
Product: 'product',
Checklist: 'checklist',
} as const;
export type ReturnProcessQuestionType =

View File

@@ -7,20 +7,44 @@ export interface ReturnProcessQuestionBase {
type: ReturnProcessQuestionType;
}
export interface ReturnProcessQuestionOptions {
label: string;
value: string;
}
export interface ReturnProcessChecklistQuestion
extends ReturnProcessQuestionBase {
type: typeof ReturnProcessQuestionType.Checklist;
options: ReturnProcessChecklistQuestionOptions[];
other?: {
label: string;
value?: string;
};
nextQuestion?: ReturnProcessQuestionKey;
}
export type ReturnProcessChecklistQuestionOptions =
ReturnProcessQuestionOptions;
export interface ReturnProcessSelectQuestion extends ReturnProcessQuestionBase {
type: typeof ReturnProcessQuestionType.Select;
options: ReturnProcessQuestionSelectOptions[];
}
export interface ReturnProcessQuestionSelectOptions {
export interface ReturnProcessQuestionSelectOptions
extends ReturnProcessQuestionOptions {
label: string;
value: string;
nextQuestion?: ReturnProcessQuestionKey;
}
export interface ReturnProcessProductQuestion extends ReturnProcessQuestionBase {
export interface ReturnProcessProductQuestion
extends ReturnProcessQuestionBase {
type: typeof ReturnProcessQuestionType.Product;
nextQuestion?: ReturnProcessQuestionKey;
}
export type ReturnProcessQuestion = ReturnProcessSelectQuestion | ReturnProcessProductQuestion;
export type ReturnProcessQuestion =
| ReturnProcessSelectQuestion
| ReturnProcessProductQuestion
| ReturnProcessChecklistQuestion;

View File

@@ -33,3 +33,12 @@ export const enum ItemDefectiveValue {
Yes = 'yes',
No = 'no',
}
export const enum PackageIncompleteValue {
// Karton/Umverpackung
Ovp = 'ovp',
// Ladekabel
CharchingCable = 'charching_cable',
// Quickstart Guide
QuickstartGuide = 'quickstart_guide',
}

View File

@@ -12,39 +12,6 @@ describe('Tolino Return Process', () => {
const result = validateTolinoQuestions(answers);
expect(result).toEqual({ state: EligibleForReturnState.Eligible });
});
it('should return Eligible when damaged and has significant defects', () => {
const answers = {
[ReturnProcessQuestionKey.ItemCondition]: 'damaged',
[ReturnProcessQuestionKey.DevicePower]: 'yes',
};
const result = validateTolinoQuestions(answers);
expect(result).toEqual({ state: EligibleForReturnState.Eligible });
});
it('should return NotEligible when damaged but no significant defects', () => {
const answers = {
[ReturnProcessQuestionKey.ItemCondition]: 'damaged',
[ReturnProcessQuestionKey.DevicePower]: 'no',
[ReturnProcessQuestionKey.PackageComplete]: 'no',
[ReturnProcessQuestionKey.CaseCondition]: 'no',
[ReturnProcessQuestionKey.UsbPort]: 'no',
};
const result = validateTolinoQuestions(answers);
expect(result).toEqual({
state: EligibleForReturnState.NotEligible,
reason: 'No significant defects found',
});
});
it('should return NotEligible for invalid answers', () => {
const answers = {};
const result = validateTolinoQuestions(answers);
expect(result).toEqual({
state: EligibleForReturnState.NotEligible,
reason: 'Invalid answers',
});
});
});
describe('Registry mappings', () => {

View File

@@ -9,6 +9,7 @@ import {
import {
ItemConditionValue,
ItemDefectiveValue,
PackageIncompleteValue,
ReturnReasonValue,
} from './constants';
@@ -60,7 +61,7 @@ export const tolinoQuestions: ReturnProcessQuestion[] = [
{
label: 'Ja',
value: ItemDefectiveValue.No,
nextQuestion: ReturnProcessQuestionKey.ReturnReason,
nextQuestion: ReturnProcessQuestionKey.PackageIncomplete,
},
{
label: 'Nein',
@@ -69,6 +70,29 @@ export const tolinoQuestions: ReturnProcessQuestion[] = [
},
],
},
{
key: ReturnProcessQuestionKey.PackageIncomplete,
description: 'Was fehlt?',
type: ReturnProcessQuestionType.Checklist,
options: [
{
label: 'Karton/Umverpackung',
value: PackageIncompleteValue.Ovp,
},
{
label: 'Ladekabel',
value: PackageIncompleteValue.CharchingCable,
},
{
label: 'Quickstart Guide',
value: PackageIncompleteValue.QuickstartGuide,
},
],
other: {
label: 'Sonstiges',
},
nextQuestion: ReturnProcessQuestionKey.CaseCondition,
},
{
key: ReturnProcessQuestionKey.CaseCondition,
description: 'Hat das Gehäuse Mängel oder ist zerkratzt?',
@@ -137,45 +161,11 @@ export const tolinoQuestions: ReturnProcessQuestion[] = [
/**
* Validates the answers for the Tolino return process and determines return eligibility.
*
* The validation logic follows these rules:
* 1. If item is in original packaging, it's always eligible for return
* 2. If item is damaged, it requires at least one defect to be eligible
* 3. If no valid condition is provided, the item is not eligible
*
* @param {ReturnProcessAnswers} answers - A record of answers keyed by question keys
* @returns {EligibleForReturn} Object containing eligibility state and reason if applicable
*/
export function validateTolinoQuestions(
answers: ReturnProcessAnswers,
): EligibleForReturn {
if (
answers[ReturnProcessQuestionKey.ItemCondition] ===
ItemConditionValue.OriginalPackaging
) {
return { state: EligibleForReturnState.Eligible };
} else if (
answers[ReturnProcessQuestionKey.ItemCondition] ===
ItemConditionValue.Damaged
) {
if (
answers[ReturnProcessQuestionKey.DevicePower] ===
ItemDefectiveValue.Yes ||
answers[ReturnProcessQuestionKey.PackageComplete] ===
ItemDefectiveValue.Yes ||
answers[ReturnProcessQuestionKey.CaseCondition] ===
ItemDefectiveValue.Yes ||
answers[ReturnProcessQuestionKey.UsbPort] === ItemDefectiveValue.Yes
) {
return { state: EligibleForReturnState.Eligible };
}
return {
state: EligibleForReturnState.NotEligible,
reason: 'No significant defects found',
};
}
return {
state: EligibleForReturnState.NotEligible,
reason: 'Invalid answers',
};
return { state: EligibleForReturnState.Eligible };
}

View File

@@ -8,6 +8,7 @@ import {
} from './models';
import { CategoryQuestions, CategoryQuestionValidators } from './questions';
import { KeyValue } from '@angular/common';
import { ReturnProcessChecklistAnswerSchema } from './schemas';
/**
* Service responsible for managing the return process workflow.
@@ -57,7 +58,9 @@ export class ReturnProcessService {
process.productCategory || process.receiptItem.features?.['category'];
if (category) {
return CategoryQuestionValidators[category];
return CategoryQuestionValidators[
category as keyof typeof CategoryQuestionValidators
];
}
return undefined;
}
@@ -102,6 +105,16 @@ export class ReturnProcessService {
questionKey = process.answers[question.key]
? question.nextQuestion
: undefined;
} else if (question.type === ReturnProcessQuestionType.Checklist) {
const answer = ReturnProcessChecklistAnswerSchema.parse(
process.answers[question.key],
);
if (answer.options?.length || answer.other) {
questionKey = question.nextQuestion;
} else {
questionKey = undefined;
}
} else {
console.error('Unknown question type', question);
break;

View File

@@ -1,2 +1,3 @@
export * from './fetch-return-details';
export * from './fetch-return-details.schema';
export * from './query-token.schema';
export * from './return-process-question-answer.schema';

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ReturnProcessChecklistAnswerSchema = z.object({
options: z.array(z.string()).default([]),
other: z.string().optional(),
});
export type ReturnProcessChecklistAnswer = z.infer<
typeof ReturnProcessChecklistAnswerSchema
>;

View File

@@ -0,0 +1,24 @@
@let q = question();
@if (q) {
<div>
{{ q.description }}
</div>
<div>
<ui-checklist [(ngModel)]="options">
@for (option of q.options; track option.label) {
<label uiCheckboxLabel>
<ui-checkbox>
<input type="checkbox" [uiChecklistValue]="option.value" />
</ui-checkbox>
{{ option.label }}
</label>
}
</ui-checklist>
</div>
@if (q.other) {
<div></div>
<ui-textarea [label]="q.other.label">
<textarea rows="4" [(ngModel)]="other"></textarea>
</ui-textarea>
}
}

View File

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

View File

@@ -0,0 +1,88 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
linkedSignal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
ReturnProcessChecklistAnswer,
ReturnProcessChecklistAnswerSchema,
ReturnProcessChecklistQuestion,
ReturnProcessStore,
} from '@isa/oms/data-access';
import {
ChipOptionComponent,
ChipsComponent,
CheckboxComponent,
ChecklistComponent,
CheckboxLabelDirective,
ChecklistValueDirective,
TextareaComponent,
} from '@isa/ui/input-controls';
import { isEqual } from 'lodash';
import { z } from 'zod';
@Component({
selector: 'oms-feature-return-process-checklist-question',
templateUrl: './return-process-checklist-question.component.html',
styleUrls: ['./return-process-checklist-question.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
FormsModule,
ChipsComponent,
ChipOptionComponent,
CheckboxComponent,
ChecklistComponent,
ChecklistValueDirective,
CheckboxLabelDirective,
TextareaComponent,
],
})
export class ReturnProcessChecklistQuestionComponent {
#returnProcessStore = inject(ReturnProcessStore);
processId = input.required<number>();
question = input.required<ReturnProcessChecklistQuestion>();
currentAnswer = computed(() => {
const currentAnswersRaw =
this.#returnProcessStore.entityMap()[this.processId()]?.answers[
this.question().key
];
if (!currentAnswersRaw) {
return ReturnProcessChecklistAnswerSchema.parse({});
}
return ReturnProcessChecklistAnswerSchema.parse(currentAnswersRaw);
});
options = linkedSignal(() => this.currentAnswer().options, {
equal: isEqual,
});
other = linkedSignal(() => this.currentAnswer().other, { equal: isEqual });
valueChangesEffect = effect(() => {
const options = this.options();
const other = this.other();
untracked(() => {
this.updateAnswer({ options, other });
});
});
updateAnswer(answer: ReturnProcessChecklistAnswer) {
this.#returnProcessStore.setAnswer(
this.processId(),
this.question().key,
answer,
);
}
}

View File

@@ -20,18 +20,24 @@
@for (question of questions(); track question.key; let last = $last) {
<div class="item-content" [class.with-border-bottom]="!last">
@switch (question.type) {
@case ('select') {
@case (ReturnProcessQuestionType.Select) {
<oms-feature-return-process-select-question
[processId]="returnProcessId()"
[question]="question"
></oms-feature-return-process-select-question>
}
@case ('product') {
@case (ReturnProcessQuestionType.Product) {
<oms-feature-return-process-product-question
[processId]="returnProcessId()"
[question]="question"
></oms-feature-return-process-product-question>
}
@case (ReturnProcessQuestionType.Checklist) {
<oms-feature-return-process-checklist-question
[processId]="returnProcessId()"
[question]="question"
></oms-feature-return-process-checklist-question>
}
@default {
{{ question | json }}
}

View File

@@ -9,11 +9,13 @@ import {
import {
ReturnProcess,
ReturnProcessQuestion,
ReturnProcessQuestionType,
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 { ReturnProcessChecklistQuestionComponent } from './return-process-checklist-question/return-process-checklist-question.component';
import {
DropdownButtonComponent,
DropdownOptionComponent,
@@ -28,6 +30,7 @@ import {
JsonPipe,
ReturnProcessSelectQuestionComponent,
ReturnProcessProductQuestionComponent,
ReturnProcessChecklistQuestionComponent,
DropdownButtonComponent,
DropdownOptionComponent,
],
@@ -37,6 +40,8 @@ export class ReturnProcessQuestionsComponent {
// Renamed service to match naming conventions
#returnProcessService = inject(ReturnProcessService);
ReturnProcessQuestionType = ReturnProcessQuestionType;
returnProcessId = input.required<number>();
returnProcess = computed<ReturnProcess | undefined>(() => {

View File

@@ -1,7 +1,11 @@
export * from './lib/checkbox/checkbox-label.directive';
export * from './lib/checkbox/checkbox.component';
export * from './lib/checkbox/checklist-value.directive';
export * from './lib/checkbox/checklist.component';
export * from './lib/core/input-control.directive';
export * from './lib/dropdown/dropdown.component';
export * from './lib/dropdown/dropdown.types';
export * from './lib/text-field/text-field.component';
export * from './lib/text-field/textarea.component';
export * from './lib/chips/chips.component';
export * from './lib/chips/chip-option.component';

View File

@@ -1,4 +1,6 @@
@use './lib/checkbox/checkbox';
@use './lib/chips/chips';
@use './lib/dropdown/dropdown';
@use './lib/text-field/text-field.scss';
@use "./lib/checkbox/checkbox";
@use "./lib/checkbox/checklist";
@use "./lib/chips/chips";
@use "./lib/dropdown/dropdown";
@use "./lib/text-field/text-field";
@use "./lib/text-field/textarea";

View File

@@ -1,3 +1,11 @@
.ui-checkbox-label {
@apply inline-flex items-center gap-4 text-isa-neutral-900 isa-text-body-2-regular;
}
.ui-checkbox-label:has(:checked) {
@apply isa-text-body-2-bold;
}
.ui-checkbox.ui-checkbox__checkbox {
@apply relative inline-flex p-3 items-center justify-center rounded-lg bg-isa-white size-6 border border-solid border-isa-neutral-900;
font-size: 1.5rem;

View File

@@ -0,0 +1,3 @@
.ui-checklist {
@apply inline-grid grid-flow-row gap-6;
}

View File

@@ -0,0 +1,9 @@
import { Directive } from '@angular/core';
@Directive({
selector: 'label[uiCheckboxLabel]',
host: {
class: 'ui-checkbox-label',
},
})
export class CheckboxLabelDirective {}

View File

@@ -13,7 +13,8 @@ export const CheckboxAppearance = {
Checkbox: 'checkbox',
} as const;
export type CheckboxAppearance = (typeof CheckboxAppearance)[keyof typeof CheckboxAppearance];
export type CheckboxAppearance =
(typeof CheckboxAppearance)[keyof typeof CheckboxAppearance];
@Component({
selector: 'ui-checkbox',

View File

@@ -0,0 +1,21 @@
import { Directive, input, model } from '@angular/core';
@Directive({
selector: '[uiChecklistValue]',
host: {
'class': 'ui-checklist-value',
'[checked]': 'checked()',
'(change)': 'onChange($event)',
},
})
export class ChecklistValueDirective {
uiChecklistValue = input<unknown>(null);
checked = model<boolean>(false);
onChange(event: Event) {
if (event.target instanceof HTMLInputElement) {
this.checked.set(event.target.checked);
}
}
}

View File

@@ -0,0 +1,90 @@
import {
ChangeDetectionStrategy,
Component,
contentChildren,
model,
effect,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { CdkObserveContent } from '@angular/cdk/observers';
import { ChecklistValueDirective } from './checklist-value.directive';
import { isEqual } from 'lodash';
@Component({
selector: 'ui-checklist',
template: '<ng-content></ng-content>',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CdkObserveContent],
host: {
'[class]': '["ui-checklist"]',
},
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: ChecklistComponent,
multi: true,
},
],
})
export class ChecklistComponent implements ControlValueAccessor {
value = model<unknown[]>([]);
onChange?: (value: unknown[]) => void;
onTouched?: () => void;
valueDirectives = contentChildren(ChecklistValueDirective, {
descendants: true,
});
valueDirectivesEffect = effect(() => {
const valueDirectives = this.valueDirectives();
const currentValue = this.value() ?? [];
const nextValue = structuredClone(currentValue);
for (const directive of valueDirectives) {
const value = directive.uiChecklistValue();
const checked = directive.checked();
if (checked) {
if (!nextValue.includes(value)) {
nextValue.push(value);
}
} else {
const index = nextValue.indexOf(value);
if (index !== -1) {
nextValue.splice(index, 1);
}
}
}
if (isEqual(currentValue, nextValue)) {
return;
}
this.value.set(nextValue);
this.onChange?.(nextValue);
this.onTouched?.();
});
writeValue(obj: unknown): void {
if (Array.isArray(obj)) {
this.value.set(obj);
for (const directive of this.valueDirectives()) {
const value = directive.uiChecklistValue();
const checked = obj.includes(value);
directive.checked.set(checked);
}
} else {
this.value.set([]);
}
}
registerOnChange(fn: (value: unknown[]) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
}

View File

@@ -0,0 +1,24 @@
.ui-textarea {
@apply px-2 py-1;
@apply bg-white text-isa-neutral-900 isa-text-body-2-regular;
@apply inline-flex flex-col gap-1;
@apply border border-solid rounded-lg;
textarea {
&:focus {
@apply outline-none;
}
&:placeholder {
@apply text-isa-neutral-400;
}
}
}
.ui-textarea__empty {
@apply border-isa-neutral-600;
}
.ui-textarea__not-empty {
@apply border-isa-neutral-900;
}

View File

@@ -0,0 +1,4 @@
@if (label()) {
<div class="ui-textarea__label">{{ label() }}</div>
}
<ng-content></ng-content>

View File

@@ -0,0 +1,19 @@
import { Component, computed, contentChild, input } from '@angular/core';
import { NgControl } from '@angular/forms';
@Component({
selector: 'ui-textarea',
templateUrl: './textarea.component.html',
host: { '[class]': '["ui-textarea", controlEmptyClass()]' },
})
export class TextareaComponent {
label = input<string>();
ngControl = contentChild(NgControl);
controlEmptyClass = computed(() => {
return this.ngControl()?.value
? 'ui-textarea__not-empty'
: 'ui-textarea__empty';
});
}