Files
Lorenz Hilpert 1784e08ce6 chore: update project configurations to skip CI for specific libraries
Added "skip:ci" tag to multiple project configurations to prevent CI runs
for certain libraries. This change affects the following libraries:
crm-feature-customer-card-transactions, crm-feature-customer-loyalty-cards,
oms-data-access, oms-feature-return-details, oms-feature-return-process,
oms-feature-return-summary, remission-data-access, remission-feature-remission-list,
remission-feature-remission-return-receipt-details, remission-feature-remission-return-receipt-list,
remission-shared-remission-start-dialog, remission-shared-return-receipt-actions,
shared-address, shared-delivery, ui-carousel, and ui-dialog.

Also updated CI command in package.json to exclude tests with the "skip:ci" tag.
2025-11-20 17:24:35 +01:00
..

@isa/oms/feature/return-process

A comprehensive Angular feature library for managing product returns with dynamic question flows, validation, and backend integration. Part of the Order Management System (OMS) domain.

Overview

The Return Process feature library provides a complete workflow for processing product returns in a retail environment. It handles complex question flows based on product categories (books, electronics, Tolino devices, etc.), validates user input in real-time, checks return eligibility through both frontend and backend validation, and guides users through the entire return journey from initial selection to final confirmation.

The library integrates with the OMS data-access layer for state management and business logic, leverages NgRx Signals for reactive state updates, and provides a type-safe, schema-validated approach to collecting return information.

Table of Contents

Features

  • Dynamic Question Flows - Category-specific questions for books, electronics, Tolino devices, and more
  • Five Question Types - Select (chips), Product (EAN search), Checklist (multi-select), Info (display), and Group (nested questions)
  • Real-time Validation - Frontend and backend validation with immediate feedback
  • Progress Tracking - Visual progress bar showing question completion status
  • Eligibility Checking - Automatic determination of return eligibility based on answers
  • Barcode Scanning - Integrated scanner support for product identification
  • Type-safe State - Zod schema validation for all user inputs
  • Automatic Navigation - Conditional routing based on process completion
  • Session Persistence - Answers preserved across browser sessions via IndexedDB
  • Responsive Design - Tailwind CSS with ISA design system integration

Quick Start

1. Add Routes to Your Application

import { Route } from '@angular/router';

export const routes: Route[] = [
  {
    path: 'return-process/:id',
    loadChildren: () =>
      import('@isa/oms/feature/return-process').then((m) => m.routes),
  },
];

2. Initialize a Return Process

import { Component, inject } from '@angular/core';
import { ReturnProcessStore } from '@isa/oms/data-access';
import { Router } from '@angular/router';

@Component({
  selector: 'app-receipt-selection',
  template: '...'
})
export class ReceiptSelectionComponent {
  #returnProcessStore = inject(ReturnProcessStore);
  #router = inject(Router);

  async startReturn(receipt: Receipt, items: ReceiptItem[]): Promise<void> {
    const processId = Date.now(); // Unique process ID

    // Initialize the return process
    this.#returnProcessStore.startProcess({
      processId,
      returns: [
        {
          receipt,
          items: items.map(item => ({
            receiptItem: item,
            quantity: 1,
            category: 'Buch/Kalender' // Product category
          }))
        }
      ]
    });

    // Navigate to return process
    await this.#router.navigate(['/return-process', processId]);
  }
}

3. The Library Handles the Rest

Once initialized, the library automatically:

  • Displays appropriate questions based on product category
  • Validates answers using Zod schemas
  • Checks eligibility with backend API
  • Shows progress and conditional navigation
  • Persists state across sessions

Core Components

ReturnProcessComponent

Main orchestrator component that manages the overall return process workflow.

Selector: oms-feature-return-process

Key Responsibilities:

  • Displays all return process items for a given process ID
  • Manages navigation (back button, continue to summary)
  • Aggregates eligibility across all items
  • Validates completion before allowing navigation

Inputs: None (uses route parameter via injectTabId())

Outputs: None (navigation-based)

Template Features:

  • Back navigation button
  • Header with instructions
  • List of return process items
  • Conditional "Continue" button (only shows when all items are eligible)

Example Usage:

// Automatically rendered via routing
// Route: /return-process/:id

ReturnProcessItemComponent

Displays a single return process item with product info, questions, and eligibility status.

Selector: oms-feature-return-process-item

Inputs:

  • returnProcessId: number (required) - The unique identifier for the return process

Key Features:

  • Product information display
  • Question renderer integration
  • Progress bar visualization
  • Eligibility status indicators (checkmark/error with message)
  • Loading spinner during backend validation

Eligibility States:

  • Eligible - Green checkmark with "Artikel bereit für Rückgabe"
  • Not Eligible - Red X with specific error message
  • Unknown - Loading spinner during validation

Example:

<oms-feature-return-process-item
  [returnProcessId]="123">
</oms-feature-return-process-item>

ReturnProcessQuestionsComponent

Container component that fetches and passes questions to the renderer.

Selector: oms-feature-return-process-questions

Inputs:

  • returnProcessId: number (required) - The return process identifier

Key Responsibilities:

  • Retrieves active questions from ReturnProcessService
  • Filters questions based on product category
  • Passes questions to renderer for display

Example:

<oms-feature-return-process-questions
  [returnProcessId]="processId">
</oms-feature-return-process-questions>

ReturnProcessQuestionsRendererComponent

Recursive renderer that displays questions dynamically based on their type.

Selector: oms-feature-return-process-questions-renderer

Inputs:

  • returnProcessId: number (required) - The return process identifier
  • questions: ReturnProcessQuestion[] (required) - Array of questions to render

Supported Question Types:

  1. Select - Single-choice chip selection
  2. Product - EAN input with scanner integration
  3. Checklist - Multi-select with optional text field
  4. Info - Display-only informational content
  5. Group - Nested questions (recursive rendering)

Example:

<oms-feature-return-process-questions-renderer
  [returnProcessId]="123"
  [questions]="activeQuestions">
</oms-feature-return-process-questions-renderer>

Question Type Components

ReturnProcessSelectQuestionComponent

Single-choice question using chip UI components.

Selector: oms-feature-return-process-select-question

Inputs:

  • returnProcessId: number (required)
  • question: ReturnProcessSelectQuestion (required)

Features:

  • Reactive form control with validation
  • Automatic answer persistence to store
  • Answer removal when invalid

Example:

<oms-feature-return-process-select-question
  [returnProcessId]="123"
  [question]="selectQuestion">
</oms-feature-return-process-select-question>

ReturnProcessProductQuestionComponent

Product lookup question with EAN input and barcode scanning.

Selector: oms-feature-return-process-product-question

Inputs:

  • returnProcessId: number (required)
  • question: ReturnProcessProductQuestion (required)

Features:

  • EAN validation with custom validator
  • Barcode scanner integration
  • Product search via CatalogueSearchService
  • Product preview with image and details
  • Error states (not found, invalid EAN)
  • Auto-focus on input field

States:

  • Empty - Input field with "Check" button
  • Fetching - Loading spinner on button
  • Found - Product display with clear button
  • Not Found - Error message "Kein Artikel gefunden"
  • Invalid - Error message "Die eingegebene EAN ist ungültig"

Example:

<oms-feature-return-process-product-question
  [returnProcessId]="123"
  [question]="productQuestion">
</oms-feature-return-process-product-question>

ReturnProcessChecklistQuestionComponent

Multi-select checklist with optional "other" text field.

Selector: oms-feature-return-process-checklist-question

Inputs:

  • returnProcessId: number (required)
  • question: ReturnProcessChecklistQuestion (required)

Features:

  • Multiple option selection via checkboxes
  • Optional textarea for "other" input
  • Linked signals for reactive updates
  • Zod schema validation
  • Deep equality checking with lodash

Answer Structure:

{
  options?: string[],  // Selected checkbox values
  other?: string       // Optional text input
}

Example:

<oms-feature-return-process-checklist-question
  [returnProcessId]="123"
  [question]="checklistQuestion">
</oms-feature-return-process-checklist-question>

ReturnProcessInfoQuestionComponent

Display-only informational question (no user input).

Selector: oms-feature-return-process-info-question

Inputs:

  • question: ReturnProcessInfoQuestion (required)

Features:

  • Displays description and content list
  • No answer collection
  • Always considered "answered"

Example:

<oms-feature-return-process-info-question
  [question]="infoQuestion">
</oms-feature-return-process-info-question>

Question System Architecture

Product Categories

The question system supports six product categories, each with tailored question flows:

const ProductCategory = {
  Unknown: 'unknown',
  BookCalendar: 'Buch/Kalender',
  TonDatentraeger: 'Ton-/Datenträger',
  SpielwarenPuzzle: 'Spielwaren/Puzzle',
  SonstigesNonbook: 'Sonstiges und Non-Book',
  ElektronischeGeraete: 'Andere Elektronische Geräte',
  Tolino: 'Tolino',
};

Question Types

Five distinct question types handle different input scenarios:

const ReturnProcessQuestionType = {
  Select: 'select',       // Single choice (chips)
  Product: 'product',     // EAN lookup
  Checklist: 'checklist', // Multi-select
  Info: 'info',           // Display only
  Group: 'group',         // Nested questions
};

Question Structure

Select Question Example

{
  key: 'item-condition',
  description: 'Wie ist der Artikelzustand?',
  type: 'select',
  options: [
    {
      label: 'Neuwertig/Originalverpackt',
      value: 'Originalverpackt',
      nextQuestion: 'return-reason',
      returnInfo: { itemCondition: 'Neuwertig/Originalverpackt' }
    },
    {
      label: 'Beschädigt/Fehldruck',
      value: 'Geöffnet/Defekt',
      returnInfo: { itemCondition: 'Beschädigt/Fehldruck' }
    }
  ]
}

Product Question Example

{
  key: 'delivered-item',
  description: 'Welcher Artikel wurde geliefert?',
  type: 'product',
  nextQuestion: 'item-damaged' // Optional next question
}

Checklist Question Example

{
  key: 'package-missing-items',
  description: 'Was fehlt?',
  type: 'checklist',
  options: [
    { label: 'Karton / Umverpackung', value: 'Karton / Umverpackung' },
    { label: 'Ladekabel', value: 'Ladekabel' },
    { label: 'Quickstart-Guide', value: 'Quickstart-Guide' }
  ],
  other: {
    label: 'Sonstiges'
  }
}

Group Question Example (Nested)

{
  key: 'package-complete-group',
  type: 'group',
  questions: [
    {
      key: 'package-incomplete-info',
      description: 'Fehlende Teile',
      type: 'info',
      content: ['Item 1', 'Item 2']
    },
    {
      key: 'package-missing-items',
      description: 'Was fehlt?',
      type: 'checklist',
      options: [...]
    }
  ]
}

Question Flow and Branching

Questions support conditional branching via the nextQuestion property:

// Question 1: Item Condition
{
  key: 'item-condition',
  type: 'select',
  options: [
    {
      value: 'Originalverpackt',
      nextQuestion: 'return-reason', // Branch to return reason
    },
    {
      value: 'Geöffnet/Defekt',
      // No nextQuestion - ends flow
    }
  ]
}

// Question 2: Return Reason (shown only if OVP selected)
{
  key: 'return-reason',
  type: 'select',
  options: [
    {
      value: 'Gefällt nicht/Widerruf',
      // Ends flow
    },
    {
      value: 'Fehllieferung',
      nextQuestion: 'delivered-item', // Branch to product lookup
    }
  ]
}

// Question 3: Delivered Item (shown only if wrong item)
{
  key: 'delivered-item',
  type: 'product',
  description: 'Welcher Artikel wurde geliefert?'
}

Active Question Determination

The ReturnProcessService.activeReturnProcessQuestions() method:

  1. Starts with all category questions from CategoryQuestions registry
  2. Filters based on previous answers using nextQuestion logic
  3. Validates answers using Zod schemas
  4. Returns only questions that should be shown based on user choices
  5. Detects cyclic dependencies and throws errors

Example Flow:

// User selects "Originalverpackt" for item-condition
answers = { 'item-condition': 'Originalverpackt' };

// Active questions: ['item-condition', 'return-reason']
// ('return-reason' shown because option has nextQuestion)

// User selects "Fehllieferung" for return-reason
answers = {
  'item-condition': 'Originalverpackt',
  'return-reason': 'Fehllieferung'
};

// Active questions: ['item-condition', 'return-reason', 'delivered-item']
// ('delivered-item' shown because 'Fehllieferung' option has nextQuestion)

Category Question Registry

Questions are organized by category in the CategoryQuestions registry (from @isa/oms/data-access):

const CategoryQuestions: Record<ProductCategory, ReturnProcessQuestion[]> = {
  'Buch/Kalender': bookCalendarQuestions,
  'Ton-/Datenträger': tonDatentraegerQuestions,
  'Spielwaren/Puzzle': nonbookQuestions,
  'Sonstiges und Non-Book': nonbookQuestions,
  'Andere Elektronische Geräte': elektronischeGeraeteQuestions,
  'Tolino': tolinoQuestions,
  'unknown': []
};

API Reference

ReturnProcessComponent

Selector: oms-feature-return-process

Inputs: None (uses route parameter)

Computed Signals:

  • processId: Signal<number | null> - The current process ID from route
  • returnProcesses: Signal<ReturnProcess[]> - Filtered processes for current ID
  • canContinueToSummary: Signal<boolean> - True when all questions answered and eligible
  • canReturn: WritableSignal<boolean> - Backend validation result for all processes

Methods: None (template-driven navigation)

Template Actions:

  • location.back() - Navigate to previous page

ReturnProcessItemComponent

Selector: oms-feature-return-process-item

Inputs:

  • returnProcessId: number (required) - Unique return process identifier

Computed Signals:

  • returnProcess: Signal<ReturnProcess | undefined> - The current process entity
  • progress: Signal<{ answered: number; total: number } | undefined> - Question progress
  • eligibleForReturn: Signal<EligibleForReturn | undefined> - Frontend eligibility check
  • eligibleForReturnState: Signal<EligibleForReturnState> - Combined frontend/backend state
  • eligibleForReturnMessage: Signal<string | undefined> - Eligibility explanation message

Writable Signals:

  • canReturn: WritableSignal<CanReturn | undefined> - Backend validation result

Lifecycle:

  • Constructor Effect - Automatically validates eligibility on process changes

ReturnProcessQuestionsComponent

Selector: oms-feature-return-process-questions

Inputs:

  • returnProcessId: number (required) - Return process identifier

Computed Signals:

  • returnProcess: Signal<ReturnProcess | undefined> - Current process
  • questions: Signal<ReturnProcessQuestion[]> - Active questions for display

ReturnProcessSelectQuestionComponent

Selector: oms-feature-return-process-select-question

Inputs:

  • returnProcessId: number (required)
  • question: ReturnProcessSelectQuestion (required)

Form Control:

  • control: FormControl<string | undefined> - Reactive form control with required validation

Lifecycle:

  • Constructor - Sets up value change subscription and answer synchronization

ReturnProcessProductQuestionComponent

Selector: oms-feature-return-process-product-question

Inputs:

  • returnProcessId: number (required)
  • question: ReturnProcessProductQuestion (required)

Form Control:

  • control: FormControl<string> - EAN input with validation (required + EAN format)

Signals:

  • product: WritableSignal<Product | undefined> - Found product data
  • status: WritableSignal<{ fetching: boolean; hasResult?: boolean }> - Search status

Methods:

  • check(): void - Validates EAN and searches for product
  • resetProduct(): void - Clears product and answer
  • onScan(value: string | null): void - Handles barcode scanner input

RxJS Methods:

  • check: RxMethod<void> - Observable-based product search with error handling

ReturnProcessChecklistQuestionComponent

Selector: oms-feature-return-process-checklist-question

Inputs:

  • returnProcessId: number (required)
  • question: ReturnProcessChecklistQuestion (required)

Computed Signals:

  • currentAnswer: Signal<ReturnProcessChecklistAnswer> - Current answer from store

Linked Signals:

  • options: LinkedSignal<string[] | undefined> - Selected checkbox values
  • other: LinkedSignal<string | undefined> - Optional text input

Methods:

  • updateAnswer(answer: ReturnProcessChecklistAnswer): void - Persists answer to store

Effects:

  • valueChangesEffect - Watches signal changes and updates store

ReturnProcessInfoQuestionComponent

Selector: oms-feature-return-process-info-question

Inputs:

  • question: ReturnProcessInfoQuestion (required)

No Methods or Signals (Display-only component)

Usage Examples

Complete Return Process Flow

import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { ReturnProcessStore, Receipt, ReceiptItem } from '@isa/oms/data-access';

@Component({
  selector: 'app-return-workflow',
  template: `
    <button (click)="startReturnProcess()">
      Start Return
    </button>
  `
})
export class ReturnWorkflowComponent {
  #returnProcessStore = inject(ReturnProcessStore);
  #router = inject(Router);

  async startReturnProcess(): Promise<void> {
    // Sample data
    const receipt: Receipt = {
      id: 12345,
      printedDate: '2024-01-15',
      // ... other receipt properties
    };

    const items: ReceiptItem[] = [
      {
        id: 1,
        product: {
          ean: '9783123456789',
          name: 'Sample Book',
          category: 'Buch/Kalender'
        },
        quantity: 1,
        // ... other item properties
      }
    ];

    // Generate unique process ID (typically tab ID or timestamp)
    const processId = Date.now();

    try {
      // Initialize return process in store
      this.#returnProcessStore.startProcess({
        processId,
        returns: [
          {
            receipt,
            items: items.map(item => ({
              receiptItem: item,
              quantity: 1,
              category: 'Buch/Kalender'
            }))
          }
        ]
      });

      // Navigate to return process page
      await this.#router.navigate(['/return-process', processId]);
    } catch (error) {
      console.error('Failed to start return process:', error);
    }
  }
}

Accessing Return Process State

import { Component, computed, inject } from '@angular/core';
import { ReturnProcessStore, ReturnProcessService } from '@isa/oms/data-access';

@Component({
  selector: 'app-process-monitor',
  template: `
    <div>
      <h3>Return Process Status</h3>
      <p>Total Processes: {{ processCount() }}</p>
      <p>Completed: {{ completedCount() }}</p>

      @for (process of processes(); track process.id) {
        <div>
          <p>Process {{ process.id }}</p>
          <p>Progress: {{ getProgress(process) }}</p>
          <p>Eligible: {{ isEligible(process) }}</p>
        </div>
      }
    </div>
  `
})
export class ProcessMonitorComponent {
  #returnProcessStore = inject(ReturnProcessStore);
  #returnProcessService = inject(ReturnProcessService);

  processes = this.#returnProcessStore.entities;

  processCount = computed(() => this.processes().length);

  completedCount = computed(() => {
    return this.processes().filter(p => {
      const progress = this.#returnProcessService
        .returnProcessQuestionsProgress(p);
      return progress?.answered === progress?.total;
    }).length;
  });

  getProgress(process: ReturnProcess): string {
    const progress = this.#returnProcessService
      .returnProcessQuestionsProgress(process);
    if (!progress) return 'N/A';
    return `${progress.answered}/${progress.total}`;
  }

  isEligible(process: ReturnProcess): boolean {
    const result = this.#returnProcessService.eligibleForReturn(process);
    return result?.state === 'eligible';
  }
}

Programmatic Answer Setting

import { Component, inject } from '@angular/core';
import { ReturnProcessStore } from '@isa/oms/data-access';

@Component({
  selector: 'app-auto-answer',
  template: '...'
})
export class AutoAnswerComponent {
  #returnProcessStore = inject(ReturnProcessStore);

  autoFillAnswers(processId: number): void {
    // Set select answer
    this.#returnProcessStore.setAnswer(
      processId,
      'item-condition',
      'Originalverpackt'
    );

    // Set checklist answer
    this.#returnProcessStore.setAnswer(
      processId,
      'package-missing-items',
      {
        options: ['Ladekabel', 'Quickstart-Guide'],
        other: 'Custom missing item'
      }
    );

    // Set product answer
    this.#returnProcessStore.setAnswer(
      processId,
      'delivered-item',
      {
        ean: '9783123456789',
        name: 'Sample Product',
        catalogProductNumber: '12345',
        // ... other product properties
      }
    );

    // Remove answer
    this.#returnProcessStore.removeAnswer(
      processId,
      'item-condition'
    );
  }
}

Testing Question Eligibility

import { Component, inject, signal } from '@angular/core';
import {
  ReturnProcessService,
  ReturnProcess,
  EligibleForReturnState
} from '@isa/oms/data-access';

@Component({
  selector: 'app-eligibility-check',
  template: `
    <div>
      <h3>Eligibility Status</h3>
      @if (eligibility(); as result) {
        @switch (result.state) {
          @case ('eligible') {
            <p class="text-green-600">✓ Eligible for return</p>
          }
          @case ('not-eligible') {
            <p class="text-red-600">✗ Not eligible: {{ result.reason }}</p>
          }
          @case ('unknown') {
            <p class="text-gray-600">⏳ Checking eligibility...</p>
          }
        }
      }
    </div>
  `
})
export class EligibilityCheckComponent {
  #returnProcessService = inject(ReturnProcessService);

  process = signal<ReturnProcess>({
    id: 1,
    processId: 100,
    receiptId: 12345,
    productCategory: 'Tolino',
    quantity: 1,
    receiptDate: '2024-01-15',
    receiptItem: { /* ... */ },
    answers: {
      'item-condition': 'Originalverpackt',
      'return-reason': 'Gefällt nicht/Widerruf',
      'display-damaged': 'Nein',
      'device-power': 'Ja'
    }
  });

  eligibility = computed(() => {
    return this.#returnProcessService.eligibleForReturn(this.process());
  });
}

Routing and Navigation

Route Configuration

The library exports a routes array that defines three routes:

// libs/oms/feature/return-process/src/lib/routes.ts

export const routes: Route[] = [
  {
    path: '',
    component: ReturnProcessComponent
  },
  {
    path: 'summary',
    loadChildren: () =>
      import('@isa/oms/feature/return-summary').then((feat) => feat.routes)
  },
  {
    path: 'review',
    loadChildren: () =>
      import('@isa/oms/feature/return-review').then((feat) => feat.routes)
  }
];

Integration in Main App

// apps/isa-app/src/app/app-routing.module.ts

import { Route } from '@angular/router';

export const routes: Route[] = [
  {
    path: 'return-process/:id',
    loadChildren: () =>
      import('@isa/oms/feature/return-process').then((m) => m.routes)
  },
  // ... other routes
];

Navigation Flow

  1. Start - External component navigates to /return-process/:id
  2. Questions - User answers questions on main route (/return-process/:id)
  3. Summary - Click "Weiter" navigates to /return-process/:id/summary
  4. Review - Additional route for final review at /return-process/:id/review

Process ID Management

The library uses injectTabId() from @isa/core/tabs to retrieve the process ID:

// In ReturnProcessComponent
processId = injectTabId(); // Returns Signal<number | null>

This integrates with the tab system to support multiple concurrent return processes.

Navigation Guards

The "Weiter" (Continue) button is conditionally displayed:

@if (canContinueToSummary() && canReturn()) {
  <div class="text-right">
    <a routerLink="summary" uiButton color="brand">
      Weiter
    </a>
  </div>
}

Conditions for Navigation:

  1. All processes must exist
  2. All questions must be answered
  3. Frontend eligibility must be "eligible"
  4. Backend validation must pass

State Management

ReturnProcessStore Integration

The library uses ReturnProcessStore from @isa/oms/data-access for state management:

Store Features:

  • Entity Management - Stores ReturnProcess entities with normalized structure
  • Session Persistence - Automatically persists to IndexedDB via IDBStorageProvider
  • Computed Next ID - Generates unique IDs for new processes
  • Answer Management - Methods for setting/removing answers
  • Orphan Cleanup - Automatically removes entities when tabs close

Key Methods:

// Start new return process
ReturnProcessStore.startProcess(params: StartProcess): void

// Set answer for a question
ReturnProcessStore.setAnswer<T>(id: number, question: string, answer: T): void

// Remove answer
ReturnProcessStore.removeAnswer(id: number, question: string): void

// Remove processes by ID
ReturnProcessStore.removeAllEntitiesByProcessId(...processIds: number[]): void

// Access entities
ReturnProcessStore.entities: Signal<ReturnProcess[]>
ReturnProcessStore.entityMap: Signal<Record<number, ReturnProcess>>

Answer Structure

Answers are stored as a key-value map with question keys:

interface ReturnProcess {
  id: number;
  processId: number;
  receiptId: number;
  receiptItem: ReceiptItem;
  receiptDate: string | undefined;
  productCategory: string;
  quantity: number;
  answers: Record<string, unknown>; // Question answers
  returnReceipt?: Receipt; // Set when process completed
}

Example Answer Data:

{
  'item-condition': 'Originalverpackt',
  'return-reason': 'Fehllieferung',
  'delivered-item': {
    ean: '9783123456789',
    name: 'Sample Book',
    catalogProductNumber: '12345'
  },
  'package-missing-items': {
    options: ['Ladekabel'],
    other: 'Custom item'
  }
}

State Synchronization

Components use Angular signals and effects for reactive state updates:

// Example from ReturnProcessSelectQuestionComponent
constructor() {
  // Listen to form changes and update store
  this.control.valueChanges
    .pipe(takeUntilDestroyed())
    .subscribe((value) => {
      if (this.control.valid) {
        this.#returnProcessStore.setAnswer(
          this.returnProcessId(),
          this.question().key,
          value
        );
      } else {
        this.#returnProcessStore.removeAnswer(
          this.returnProcessId(),
          this.question().key
        );
      }
    });

  // Sync store changes back to form
  effect(() => {
    const value = this.#returnProcessStore
      .entityMap()[this.returnProcessId()]
      ?.answers[this.question().key];
    if (value !== this.control.value) {
      this.control.setValue(value as string);
    }
  });
}

Session Persistence

All return process data is automatically persisted to IndexedDB:

  • Automatic Save - Every setAnswer() and removeAnswer() triggers saveToStorage()
  • Restore on Load - State automatically restored when user returns
  • Tab Cleanup - Orphaned processes removed via onInit hook

Storage Provider:

withStorage('return-process', IDBStorageProvider)

Validation and Eligibility

Frontend Validation (Schema-Based)

All answers are validated using Zod schemas (defined in @isa/oms/data-access):

// Example schemas
const ReturnProcessQuestionSchema = {
  'item-condition': z.enum(['Originalverpackt', 'Geöffnet/Defekt']),
  'return-reason': z.enum(['Gefällt nicht/Widerruf', 'Fehllieferung']),
  'display-damaged': z.enum(['Ja', 'Nein']),
  'delivered-item': z.object({
    ean: z.string(),
    name: z.string(),
    catalogProductNumber: z.string().optional(),
    // ... more fields
  }).passthrough(),
  'package-missing-items': z.object({
    options: z.array(z.enum([...])).min(1).optional(),
    other: z.string().optional()
  })
};

Form-Level Validation

Individual question components use reactive forms with validators:

// Select Question - Required validator
control = new FormControl<string | undefined>(undefined, [
  Validators.required
]);

// Product Question - EAN validator
control = new FormControl<string>('', [
  Validators.required,
  eanValidator // Custom EAN format validator from @isa/utils/ean-validation
]);

Eligibility Determination

Eligibility is checked at two levels:

1. Frontend Eligibility (ReturnProcessService)

eligibleForReturn(returnProcess: ReturnProcess): EligibleForReturn | undefined {
  // Returns undefined if questions not answered
  // Returns { state, reason } based on category-specific logic

  switch (returnProcess.productCategory) {
    case 'Andere Elektronische Geräte':
      return isElektronischeGeraeteEligibleForReturn(returnProcess, questions);
    case 'Ton-/Datenträger':
      return isTonDatentraegerEligibleForReturn(returnProcess, questions);
    case 'Tolino':
      return isTolinoEligibleForReturn(returnProcess, questions);
    default:
      return { state: 'eligible' };
  }
}

Eligibility States:

const EligibleForReturnState = {
  NotEligible: 'not-eligible',
  Eligible: 'eligible',
  Unknown: 'unknown'
};

Example Category Logic (Tolino):

// Not eligible if display damaged
if (answers['display-damaged'] === 'Ja') {
  return {
    state: 'not-eligible',
    reason: 'Display beschädigt - Rückgabe nicht möglich'
  };
}

// Not eligible if device doesn't power on
if (answers['device-power'] === 'Nein') {
  return {
    state: 'not-eligible',
    reason: 'Gerät schaltet nicht ein - Rückgabe nicht möglich'
  };
}

// Eligible if all checks pass
return { state: 'eligible' };

2. Backend Validation (ReturnCanReturnService)

// In ReturnProcessItemComponent
constructor() {
  effect(() => {
    const returnProcess = this.returnProcess();

    untracked(async () => {
      if (returnProcess) {
        this.canReturn.set(undefined);
        try {
          const canReturnResponse = await this.#returnCanReturnService
            .canReturn(returnProcess);
          this.canReturn.set(canReturnResponse);
        } catch (error) {
          this.#logger.error('Failed to validate return process', error);
          this.canReturn.set(undefined);
        }
      }
    });
  });
}

Backend Response:

interface CanReturn {
  result: boolean;      // Can return or not
  message?: string;     // Explanation message
}

Combined Eligibility Logic

The final eligibility state combines frontend and backend validation:

eligibleForReturnState = computed(() => {
  const frontendEligible = this.eligibleForReturn()?.state;
  const backendCanReturn = this.canReturn()?.result;

  // Still validating
  if (backendCanReturn === undefined) {
    return 'unknown';
  }

  // Both must be true for eligibility
  if (backendCanReturn === true && frontendEligible === 'eligible') {
    return 'eligible';
  }

  return 'not-eligible';
});

Progress Calculation

Question progress is calculated considering nested questions:

returnProcessQuestionsProgress(returnProcess: ReturnProcess) {
  const questions = getReturnProcessQuestions(returnProcess);
  const activeQuestions = this.activeReturnProcessQuestions(returnProcess);

  // Count answered questions (including group sub-questions)
  const answered = activeQuestions.reduce((acc, q) => {
    if (q.type === 'group') {
      return acc + q.questions.filter(
        subQ => subQ.key in returnProcess.answers
      ).length;
    }
    return q.key in returnProcess.answers ? acc + 1 : acc;
  }, 0);

  // Calculate total possible questions (longest path)
  const total = calculateLongestQuestionDepth(questions, returnProcess.answers);

  return { answered, total };
}

Testing

The library uses Jest for testing (migration to Vitest pending).

Running Tests

# Run all tests for this library
npx nx test oms-feature-return-process --skip-nx-cache

# Run with coverage
npx nx test oms-feature-return-process --code-coverage --skip-nx-cache

# Run in watch mode
npx nx test oms-feature-return-process --watch

Test Structure

Current test coverage includes:

  • Component Tests - Basic component instantiation and template rendering
  • Integration Tests - Component interactions with stores and services
  • E2E Attributes - Verification of data-what and data-which attributes

Example Test

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReturnProcessComponent } from './return-process.component';

describe('ReturnProcessComponent', () => {
  let component: ReturnProcessComponent;
  let fixture: ComponentFixture<ReturnProcessComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ReturnProcessComponent],
      providers: [
        // Mock providers
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(ReturnProcessComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should have E2E attribute on back button', () => {
    const backButton = fixture.nativeElement.querySelector('[data-what="back-navigation"]');
    expect(backButton).toBeTruthy();
  });
});

E2E Testing Attributes

All interactive elements include E2E testing attributes:

<!-- Back navigation -->
<button data-what="back-navigation" (click)="location.back()">
  zurück
</button>

<!-- Summary navigation -->
<a data-what="summary-navigation" routerLink="summary">
  Weiter
</a>

<!-- Loading spinner -->
<ui-icon-button
  data-what="load-spinner"
  data-which="can-return"
  [pending]="true">
</ui-icon-button>

Testing Best Practices

  1. Mock Dependencies - Use TestBed to provide mock services
  2. Verify E2E Attributes - Always test presence of data-what and data-which
  3. Test User Flows - Simulate complete question flows
  4. Validate State Changes - Ensure store updates correctly
  5. Test Error States - Verify error handling and display

Architecture Notes

Component Hierarchy

ReturnProcessComponent (Main orchestrator)
└── ReturnProcessItemComponent (Per-item wrapper)
    ├── ReturnProductInfoComponent (Product display)
    └── ReturnProcessQuestionsComponent (Question container)
        └── ReturnProcessQuestionsRendererComponent (Recursive renderer)
            ├── ReturnProcessSelectQuestionComponent
            ├── ReturnProcessProductQuestionComponent
            ├── ReturnProcessChecklistQuestionComponent
            ├── ReturnProcessInfoQuestionComponent
            └── ReturnProcessQuestionsRendererComponent (Recursive for groups)

Data Flow

┌─────────────────────────────────────────────────────────────┐
│                     User Interaction                        │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│              Question Component (e.g., Select)              │
│  - FormControl validation                                   │
│  - User input collection                                    │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│                  ReturnProcessStore                         │
│  - setAnswer(id, key, value)                               │
│  - Persist to IndexedDB                                     │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│               ReturnProcessService                          │
│  - activeReturnProcessQuestions() → Filter visible Qs      │
│  - eligibleForReturn() → Frontend validation               │
│  - returnProcessQuestionsProgress() → Progress calc        │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│             ReturnCanReturnService (Backend)                │
│  - canReturn() → Backend validation via API                │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│                 Component Signals Update                    │
│  - eligibleForReturnState                                   │
│  - canContinueToSummary                                     │
│  - progress                                                 │
└─────────────────────────────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│                    UI Re-renders                            │
│  - Show/hide questions                                      │
│  - Update progress bar                                      │
│  - Enable/disable navigation                                │
└─────────────────────────────────────────────────────────────┘

Design Patterns

  1. Presenter Pattern - Components are thin presenters, business logic in services
  2. Reactive State - Signals and computed values for automatic UI updates
  3. Recursive Rendering - Question renderer supports nested question groups
  4. Strategy Pattern - Different question types use different rendering strategies
  5. Command Pattern - Store methods (setAnswer, removeAnswer) encapsulate actions
  6. Observer Pattern - Effects watch signals and trigger side effects

Key Architectural Decisions

1. Two-Level Validation

Decision: Implement both frontend and backend validation

Rationale:

  • Frontend validation provides immediate feedback
  • Backend validation ensures business rules and data integrity
  • Combined approach prevents invalid returns while maintaining UX

Trade-off: Additional complexity and API calls, but improved reliability

2. Recursive Question Rendering

Decision: Use recursive component for nested question groups

Rationale:

  • Tolino category requires nested questions (Group type)
  • Recursive rendering handles arbitrary nesting depth
  • Single renderer component reduces code duplication

Trade-off: Slightly more complex than flat rendering, but handles all cases

3. Signal-Based State Management

Decision: Use Angular signals instead of RxJS observables for local state

Rationale:

  • Simpler mental model for component state
  • Automatic change detection without manual subscriptions
  • Better performance with fine-grained reactivity

Trade-off: Mixing signals and observables can be confusing

4. Session Persistence via IndexedDB

Decision: Persist all return process data to IndexedDB

Rationale:

  • Preserve user progress across browser sessions
  • Handle page refreshes gracefully
  • Support multi-tab workflows

Trade-off: Storage size limitations, async complexity

Known Limitations

  1. No Undo/Redo - Once answered, questions can only be re-answered, not undone
  2. Linear Question Flow - Questions follow a tree structure, no arbitrary jumps
  3. Single Product Category - Each process item has one category, no mixing
  4. Backend Dependency - Cannot complete process without backend validation
  5. No Draft Saving - Process must be completed or abandoned, no "save for later"

Future Enhancements

Potential improvements identified:

  1. Answer History - Track answer changes for audit trail
  2. Conditional Validation - More complex validation rules based on multiple answers
  3. Question Templates - Reusable question patterns across categories
  4. Multi-step Progress - Show step-by-step progress instead of single bar
  5. Answer Pre-fill - Automatically suggest answers based on previous returns
  6. Offline Support - Queue backend validation for later when offline
  7. Migration to Vitest - Update testing framework to match newer libraries

Dependencies

Required Angular Libraries

  • @angular/core ^20.1.2 - Angular framework
  • @angular/common ^20.1.2 - Common Angular directives
  • @angular/forms ^20.1.2 - Reactive forms
  • @angular/router ^20.1.2 - Routing and navigation

Required Internal Libraries

OMS Domain

  • @isa/oms/data-access - Core business logic, stores, and services
  • @isa/oms/shared/product-info - Product information display component

UI Components

  • @isa/ui/buttons - Button and icon button components
  • @isa/ui/input-controls - Form controls (chips, checkbox, textarea, text field)
  • @isa/ui/progress-bar - Progress visualization

Shared Components

  • @isa/shared/scanner - Barcode scanner integration
  • @isa/shared/product-image - Product image directive
  • @isa/shared/product-router-link - Product navigation directive

Core Utilities

  • @isa/core/tabs - Tab management and process ID injection
  • @isa/core/logging - Centralized logging service
  • @isa/icons - Icon library

Catalogue

  • @isa/catalogue/data-access - Product search service

Utilities

  • @isa/utils/ean-validation - EAN format validation

External Dependencies

  • @ngrx/signals ^18.1.2 - Signal-based state management
  • @ngrx/operators ^18.1.2 - RxJS operators for NgRx
  • @ng-icons/core ^30.3.0 - Icon components
  • zod ^3.24.1 - Schema validation
  • rxjs ^7.8.1 - Reactive programming
  • lodash ^4.17.21 - Utility functions (isEqual)

Generated API Clients

  • @generated/swagger/oms-api - OMS API client for backend communication

Path Alias

Import from: @isa/oms/feature/return-process

License

Internal ISA Frontend library - not for external distribution.