feat(shared-task-list), feat(return-search-main), feat(return-review): create shared task list component and refactor return views

Implement new shared task list component to replace duplicate task list functionality across the application.
Update return review and search views to use the new shared component.

- Create new @isa/oms/shared/task-list library
- Extract task list functionality from return review component
- Add task list to return search main view
- Handle task completion actions through the shared component
- Fix typo in return review success message

Ref: #4942, #4972, #4974
This commit is contained in:
Nino
2025-04-30 17:34:24 +02:00
parent 0d1a65ed4a
commit 82d991fcbc
31 changed files with 520 additions and 182 deletions

View File

@@ -17,3 +17,4 @@ export * from './return-process-question';
export * from './return-process-status';
export * from './return-process';
export * from './shipping-type';
export * from './task-action-type';

View File

@@ -0,0 +1,4 @@
export interface TaskActionType {
type: 'complete' | 'damaged' | 'resell' | 'print';
taskId: number;
}

View File

@@ -0,0 +1,12 @@
<h2 class="isa-text-subtitle-1-regular">Die Rückgabe war erfolgreich!</h2>
<button
data-what="button"
data-which="print-receipt"
class="self-start"
(click)="printReceipt.emit()"
uiInfoButton
>
<span uiInfoButtonLabel>Rückgabe Bestätigung erneut drucken</span>
<ng-icon name="isaActionPrinter" uiInfoButtonIcon></ng-icon>
</button>

View File

@@ -0,0 +1,3 @@
:host {
@apply w-full flex flex-col gap-6 desktop:gap-0 desktop:flex-row desktop:justify-between desktop:items-center border-b border-solid border-isa-neutral-300 pb-6;
}

View File

@@ -0,0 +1,17 @@
import { ChangeDetectionStrategy, Component, output } from '@angular/core';
import { isaActionPrinter } from '@isa/icons';
import { InfoButtonComponent } from '@isa/ui/buttons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
@Component({
selector: 'oms-feature-return-review-header',
templateUrl: './return-review-header.component.html',
styleUrl: './return-review-header.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [InfoButtonComponent, NgIconComponent],
providers: [provideIcons({ isaActionPrinter })],
})
export class ReturnReviewHeaderComponent {
printReceipt = output<void>();
}

View File

@@ -1,23 +0,0 @@
<oms-shared-return-product-info
class="self-start"
[product]="product()"
data-what="component"
data-which="return-product-info"
></oms-shared-return-product-info>
<div data-what="review-list" data-which="processing-comment" class="self-start">
{{ processingComment() }}
</div>
@if (!item()?.completed) {
<button
type="button"
uiButton
color="secondary"
(click)="markAsDone.emit(item().id)"
data-what="button"
data-which="mark-as-done"
>
Als erledigt markieren
</button>
} @else {
Abgeschlossen
}

View File

@@ -1,3 +0,0 @@
:host {
@apply w-full grid grid-cols-[1fr,1fr,auto] p-6 text-isa-secondary-900 items-center;
}

View File

@@ -1,47 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
computed,
} from '@angular/core';
import { isaActionCheck } from '@isa/icons';
import { provideIcons } from '@ng-icons/core';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
import { Product, ReceiptItemTaskListItem } from '@isa/oms/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'oms-feature-return-review-item',
templateUrl: './return-review-item.component.html',
styleUrl: './return-review-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReturnProductInfoComponent, ButtonComponent],
providers: [provideIcons({ isaActionCheck })],
})
export class ReturnReviewItemComponent {
item = input.required<ReceiptItemTaskListItem>();
markAsDone = output<number>();
product = computed(() => {
const item = this.item();
const product = item.product;
if (product) {
return product as Product;
}
return undefined;
});
processingComment = computed(() => {
const item = this.item();
const processingComment = item.processingComment;
if (processingComment) {
return processingComment;
}
return undefined;
});
}

View File

@@ -1,22 +1,7 @@
<h2 class="isa-text-subtitle-1-regular">Die Rückgabe ware erfolgreich!</h2>
<oms-feature-return-review-header
(printReceipt)="printReceipt()"
></oms-feature-return-review-header>
<div class="flex flex-col gap-4 w-full items-center justify-center">
@for (item of taskListItems(); track item.id) {
@defer (on viewport) {
<oms-feature-return-review-item
[item]="item"
(markAsDone)="completeTask($event)"
></oms-feature-return-review-item>
} @placeholder {
<!-- TODO: Den Spinner durch Skeleton Loader Kacheln ersetzen -->
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
data-what="load-spinner"
data-which="item-placeholder"
></ui-icon-button>
</div>
}
}
</div>
<oms-shared-return-task-list
[appearance]="'review'"
></oms-shared-return-task-list>

View File

@@ -1,3 +1,3 @@
:host {
@apply flex flex-col gap-4 w-full justify-start items-center;
@apply flex flex-col w-full justify-start mt-6 p-6 bg-white rounded-2xl;
}

View File

@@ -1,74 +1,34 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
untracked,
} from '@angular/core';
import { ReturnReviewService, ReturnReviewStore } from '@isa/oms/data-access';
import { logger, provideLoggerContext } from '@isa/core/logging';
ReturnPrintReceiptsService,
ReturnProcessStore,
} from '@isa/oms/data-access';
import { injectActivatedProcessId } from '@isa/core/process';
import { firstValueFrom } from 'rxjs';
import { IconButtonComponent } from '@isa/ui/buttons';
import { ReturnReviewItemComponent } from './return-review-item/return-review-item.component';
import { ReturnTaskListComponent } from '@isa/oms/shared/task-list';
import { ReturnReviewHeaderComponent } from './return-review-header/return-review-header.component';
@Component({
selector: 'oms-feature-return-review',
templateUrl: './return-review.component.html',
styleUrl: './return-review.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IconButtonComponent, ReturnReviewItemComponent],
providers: [provideLoggerContext({ component: 'ReturnReviewComponent' })],
standalone: true,
imports: [ReturnTaskListComponent, ReturnReviewHeaderComponent],
})
export class ReturnReviewComponent {
#returnReviewService = inject(ReturnReviewService);
#returnReviewStore = inject(ReturnReviewStore);
#logger = logger();
#printReceiptsService = inject(ReturnPrintReceiptsService);
#returnProcessStore = inject(ReturnProcessStore);
processId = injectActivatedProcessId();
taskListItems = computed(() => {
async printReceipt() {
const processId = this.processId();
if (!processId) {
return [];
}
return this.#returnReviewStore.entityMap()[processId].data ?? [];
});
if (processId) {
const receiptId =
this.#returnProcessStore.entityMap()[processId].receiptId;
constructor() {
effect(() => {
const processId = this.processId();
if (processId) {
untracked(() =>
this.#returnReviewStore.fetchTaskListItems({ processId }),
);
if (receiptId) {
await this.#printReceiptsService.printReturns([receiptId]);
}
});
effect(() => {
const taskListItems = this.taskListItems();
console.log(taskListItems);
});
}
async completeTask(taskId: number) {
try {
const processId = this.processId();
const result = await firstValueFrom(
this.#returnReviewService.completeTask(taskId),
);
if (result && processId) {
this.#returnReviewStore.updateTaskListItem({
processId,
taskListItem: result,
});
}
} catch (error) {
this.#logger.error('Error completing task', error, {
function: 'completeTask',
});
}
}
}

View File

@@ -1,34 +1,43 @@
<div class="flex flex-col items-center justify-center gap-4">
<h1 class="isa-text-subtitle-1-regular">Rückgabe starten</h1>
<p class="isa-text-body-1-regular text-center">
Scannen Sie den QR-Code auf der Rechnung oder suchen Sie den Beleg
<br />
via Rechnungsnummer, E-Mail-Adresse oder Kundennamen
</p>
</div>
<filter-search-bar-input
class="mt-[1.88rem] mb-[3.12rem]"
inputKey="qs"
(triggerSearch)="onSearch()"
></filter-search-bar-input>
@if (entityPending()) {
<div class="h-12 w-full flex items-center justify-center mb-12">
<ui-icon-button
name=""
[pending]="true"
[color]="'tertiary'"
></ui-icon-button>
<div class="flex flex-col pt-12 items-center">
<div class="flex flex-col items-center justify-center gap-4">
<h1 class="isa-text-subtitle-1-regular">Rückgabe starten</h1>
<p class="isa-text-body-1-regular text-center">
Scannen Sie den QR-Code auf der Rechnung oder suchen Sie den Beleg
<br />
via Rechnungsnummer, E-Mail-Adresse oder Kundennamen
</p>
</div>
}
<div class="flex flex-row gap-2">
@for (filterInput of filterInputs(); track filterInput.key) {
<filter-input-menu-button
[filterInput]="filterInput"
(applied)="onSearch()"
>
</filter-input-menu-button>
<filter-search-bar-input
class="mt-[1.88rem] mb-[3.12rem]"
inputKey="qs"
(triggerSearch)="onSearch()"
></filter-search-bar-input>
@if (entityPending()) {
<div class="h-12 w-full flex items-center justify-center mb-12">
<ui-icon-button
name=""
[pending]="true"
[color]="'tertiary'"
></ui-icon-button>
</div>
}
<div class="flex flex-row gap-2">
@for (filterInput of filterInputs(); track filterInput.key) {
<filter-input-menu-button
[filterInput]="filterInput"
(applied)="onSearch()"
>
</filter-input-menu-button>
}
</div>
</div>
<div class="flex flex-col items-center gap-6 w-[24.25rem] justify-self-center">
<span class="isa-text-subtitle-2-bold self-start">OFFENE AUFGABEN</span>
<oms-shared-return-task-list
[appearance]="'main'"
></oms-shared-return-task-list>
</div>

View File

@@ -1,3 +1,3 @@
:host {
@apply flex flex-col pt-12 items-center;
@apply w-full grid gap-16 grid-flow-row justify-center desktop:grid-cols-[1fr,auto] desktop:gap-6;
}

View File

@@ -12,6 +12,7 @@ import {
ReturnSearchStatus,
ReturnSearchStore,
} from '@isa/oms/data-access';
import { ReturnTaskListComponent } from '@isa/oms/shared/task-list';
import {
FilterService,
SearchBarInputComponent,
@@ -28,6 +29,7 @@ import { IconButtonComponent } from '@isa/ui/buttons';
SearchBarInputComponent,
IconButtonComponent,
FilterInputMenuButtonComponent,
ReturnTaskListComponent,
],
})
export class ReturnSearchMainComponent {

View File

@@ -0,0 +1,7 @@
# return-task-list
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test return-task-list` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'omsShared',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'oms-shared',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,21 @@
export default {
displayName: 'return-task-list',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/oms/shared/task-list',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -0,0 +1,20 @@
{
"name": "return-task-list",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/oms/shared/task-list/src",
"prefix": "oms-shared",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/oms/shared/task-list/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/return-task-list/return-task-list.component';

View File

@@ -0,0 +1,38 @@
<oms-shared-return-product-info
[product]="product()"
data-what="component"
data-which="return-product-info"
></oms-shared-return-product-info>
<div class="p-4 flex flex-col gap-4 rounded-lg bg-isa-secondary-100">
<div
data-what="review-list"
data-which="processing-comment"
class="isa-text-body-2-bold"
>
{{ processingComment() }}
</div>
@if (!item()?.completed) {
<button
class="flex items-center gap-2 self-end"
type="button"
uiButton
color="primary"
(click)="onActionClick({ type: 'complete' })"
data-what="button"
data-which="complete"
>
<ng-icon name="isaActionCheck" uiButtonIcon></ng-icon>
Als erledigt markieren
</button>
} @else {
<span
class="flex items-center gap-2 text-isa-accent-green isa-text-body-2-bold self-end"
data-what="info"
data-which="completed"
>
<ng-icon name="isaActionCheck" uiButtonIcon></ng-icon>
Abgeschlossen
</span>
}
</div>

View File

@@ -0,0 +1,11 @@
.oms-shared-return-task-list-item {
@apply w-full;
}
.oms-shared-return-task-list-item__review {
@apply grid grid-cols-[1fr,1fr] desktop:grid-cols-[1fr,1fr,minmax(20rem,auto)] gap-6 py-6 text-isa-secondary-900 items-center border-b border-solid border-isa-neutral-300 last:pb-0 last:border-none;
}
.oms-shared-return-task-list-item__main {
@apply bg-white rounded-2xl p-6 flex flex-col gap-6;
}

View File

@@ -0,0 +1,68 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
ViewEncapsulation,
} from '@angular/core';
import { isaActionCheck } from '@isa/icons';
import {
Product,
ReceiptItemTaskListItem,
TaskActionType,
} from '@isa/oms/data-access';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
import { ButtonComponent } from '@isa/ui/buttons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
@Component({
selector: 'oms-shared-return-task-list-item',
templateUrl: './return-task-list-item.component.html',
styleUrl: './return-task-list-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReturnProductInfoComponent, ButtonComponent, NgIconComponent],
providers: [provideIcons({ isaActionCheck })],
host: {
'[class]': "['oms-shared-return-task-list-item', appearanceClass()]",
},
encapsulation: ViewEncapsulation.None,
})
export class ReturnTaskListItemComponent {
appearance = input<'main' | 'review'>('main');
item = input.required<ReceiptItemTaskListItem>();
action = output<TaskActionType>();
appearanceClass = computed(
() => `oms-shared-return-task-list-item__${this.appearance()}`,
);
product = computed(() => {
const item = this.item();
const product = item.product;
if (product) {
return product as Product;
}
return undefined;
});
processingComment = computed(() => {
const item = this.item();
const processingComment = item.processingComment;
if (processingComment) {
return processingComment;
}
return undefined;
});
onActionClick(type: Omit<TaskActionType, 'taskId'>) {
const taskId = this.item().id;
const actionType = { ...type, taskId };
this.action.emit(actionType);
}
}

View File

@@ -0,0 +1,27 @@
@let taskList = taskListItems();
@if (taskList?.length !== 0) {
<div
class="flex flex-col w-full items-center justify-center"
[class.list-gap]="appearance() === 'main'"
>
@for (item of taskList; track item.id) {
@defer (on viewport) {
<oms-shared-return-task-list-item
[appearance]="appearance()"
[item]="item"
(action)="handleAction($event)"
></oms-shared-return-task-list-item>
} @placeholder {
<!-- TODO: Den Spinner durch Skeleton Loader Kacheln ersetzen -->
<div class="h-[11rem] w-full flex items-center justify-center">
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
data-what="load-spinner"
data-which="item-placeholder"
></ui-icon-button>
</div>
}
}
</div>
}

View File

@@ -0,0 +1,3 @@
.list-gap {
@apply gap-6;
}

View File

@@ -0,0 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReturnTaskListComponent } from './return-task-list.component';
describe('ReturnTaskListComponent', () => {
let component: ReturnTaskListComponent;
let fixture: ComponentFixture<ReturnTaskListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReturnTaskListComponent],
}).compileComponents();
fixture = TestBed.createComponent(ReturnTaskListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,99 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
untracked,
ViewEncapsulation,
} from '@angular/core';
import { ReturnTaskListItemComponent } from './return-task-list-item/return-task-list-item.component';
import {
ReturnReviewService,
ReturnReviewStore,
TaskActionType,
} from '@isa/oms/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
import { firstValueFrom } from 'rxjs';
import { injectActivatedProcessId } from '@isa/core/process';
import { logger, provideLoggerContext } from '@isa/core/logging';
@Component({
selector: 'oms-shared-return-task-list',
templateUrl: './return-task-list.component.html',
styleUrl: './return-task-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReturnTaskListItemComponent, IconButtonComponent],
providers: [provideLoggerContext({ component: 'ReturnTaskListComponent' })],
host: {
'[class]': "['oms-shared-return-task-list', appearanceClass()]",
},
encapsulation: ViewEncapsulation.None,
})
export class ReturnTaskListComponent {
appearance = input<'main' | 'review'>('main');
#returnReviewService = inject(ReturnReviewService);
#returnReviewStore = inject(ReturnReviewStore);
#logger = logger();
processId = injectActivatedProcessId();
appearanceClass = computed(
() => `oms-shared-return-task-list__${this.appearance()}`,
);
taskListItems = computed(() => {
const processId = this.processId();
if (!processId) {
return [];
}
return this.#returnReviewStore.entityMap()[processId].data ?? [];
});
constructor() {
effect(() => {
const processId = this.processId();
if (processId) {
untracked(() =>
this.#returnReviewStore.fetchTaskListItems({ processId }),
);
}
});
effect(() => {
const taskListItems = this.taskListItems();
console.log(taskListItems);
});
}
async handleAction(action: TaskActionType) {
switch (action.type) {
case 'complete':
await this.completeTask(action.taskId);
break;
// TODO: Implement other action types
}
}
async completeTask(taskId: number) {
try {
const processId = this.processId();
const result = await firstValueFrom(
this.#returnReviewService.completeTask(taskId),
);
if (result && processId) {
this.#returnReviewStore.updateTaskListItem({
processId,
taskListItem: result,
});
}
} catch (error) {
this.#logger.error('Error completing task', error, {
function: 'completeTask',
});
}
}
}

View File

@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -67,6 +67,7 @@
"@isa/oms/shared/product-info": [
"libs/oms/shared/product-info/src/index.ts"
],
"@isa/oms/shared/task-list": ["libs/oms/shared/task-list/src/index.ts"],
"@isa/oms/utils/translation": ["libs/oms/utils/translation/src/index.ts"],
"@isa/shared/filter": ["libs/shared/filter/src/index.ts"],
"@isa/shared/product-image": ["libs/shared/product-image/src/index.ts"],