Compare commits

...

2 Commits

Author SHA1 Message Date
Lorenz Hilpert
9623a8ede5 feat(remission): enhance has-pending-remission-hint component with dynamic days threshold and loading/error handling 2025-07-11 16:09:40 +02:00
Lorenz Hilpert
741e1e6d15 feat(remission): add has-pending-remission-hint component and directive
Introduce a new component and directive to display a hint when there are
incomplete remissions older than 7 days. The directive manages the rendering
of the hint based on the state of the remission return receipts.

Refs: #5136
2025-07-10 19:19:33 +02:00
9 changed files with 594 additions and 256 deletions

View File

@@ -9,14 +9,12 @@ You are in an nx workspace using Nx 21.2.1 and npm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
# General Guidelines
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
# Generation Guidelines
If the user wants to generate something, use the following flow:
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
@@ -31,11 +29,19 @@ If the user wants to generate something, use the following flow:
- use the information provided in the log file to answer the user's question or continue with what they were doing
# Running Tasks Guidelines
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.
# CI Error Guidelines
If the user wants help with fixing an error in their CI pipeline, use the following flow:
- Retrieve the list of current CI Pipeline Executions (CIPEs) using the 'nx_cloud_cipe_details' tool
- If there are any errors, use the 'nx_cloud_fix_cipe_failure' tool to retrieve the logs for a specific task
- Use the task logs to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- Make sure that the problem is fixed by running the task that you passed into the 'nx_cloud_fix_cipe_failure' tool

View File

@@ -15,14 +15,14 @@ import { logger } from '@isa/core/logging';
/**
* Service responsible for managing remission return receipts.
* Handles fetching completed and incomplete return receipts from the inventory API.
*
*
* @class RemissionReturnReceiptService
* @injectable
*
*
* @example
* // Inject the service
* constructor(private remissionReturnReceiptService: RemissionReturnReceiptService) {}
*
*
* // Fetch completed receipts
* const completedReceipts = await this.remissionReturnReceiptService
* .fetchCompletedRemissionReturnReceipts();
@@ -39,12 +39,12 @@ export class RemissionReturnReceiptService {
/**
* Fetches all completed remission return receipts for the assigned stock.
* Returns receipts marked as completed within the last 7 days.
*
*
* @async
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Return[]>} Array of completed return objects with receipts
* @throws {ResponseArgsError} When the API request fails
*
*
* @example
* const controller = new AbortController();
* const completedReturns = await service
@@ -54,15 +54,15 @@ export class RemissionReturnReceiptService {
abortSignal?: AbortSignal,
): Promise<Return[]> {
this.#logger.debug('Fetching completed remission return receipts');
const assignedStock =
await this.#remissionStockService.fetchAssignedStock(abortSignal);
this.#logger.info('Fetching completed returns from API', () => ({
stockId: assignedStock.id,
startDate: subDays(new Date(), 7).toISOString()
startDate: subDays(new Date(), 7).toISOString(),
}));
let req$ = this.#returnService.ReturnQueryReturns({
stockId: assignedStock.id,
queryToken: {
@@ -80,27 +80,30 @@ export class RemissionReturnReceiptService {
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch completed returns', new Error(res.message || 'Unknown error'));
this.#logger.error(
'Failed to fetch completed returns',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const returns = (res?.result as Return[]) || [];
this.#logger.debug('Successfully fetched completed returns', () => ({
returnCount: returns.length
returnCount: returns.length,
}));
return returns;
}
/**
* Fetches all incomplete remission return receipts for the assigned stock.
* Returns receipts not yet marked as completed within the last 7 days.
*
*
* @async
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Return[]>} Array of incomplete return objects with receipts
* @throws {ResponseArgsError} When the API request fails
*
*
* @example
* const incompleteReturns = await service
* .fetchIncompletedRemissionReturnReceipts();
@@ -109,15 +112,15 @@ export class RemissionReturnReceiptService {
abortSignal?: AbortSignal,
): Promise<Return[]> {
this.#logger.debug('Fetching incomplete remission return receipts');
const assignedStock =
await this.#remissionStockService.fetchAssignedStock(abortSignal);
this.#logger.info('Fetching incomplete returns from API', () => ({
stockId: assignedStock.id,
startDate: subDays(new Date(), 7).toISOString()
startDate: subDays(new Date(), 7).toISOString(),
}));
let req$ = this.#returnService.ReturnQueryReturns({
stockId: assignedStock.id,
queryToken: {
@@ -135,22 +138,25 @@ export class RemissionReturnReceiptService {
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch incomplete returns', new Error(res.message || 'Unknown error'));
this.#logger.error(
'Failed to fetch incomplete returns',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const returns = (res?.result as Return[]) || [];
this.#logger.debug('Successfully fetched incomplete returns', () => ({
returnCount: returns.length
returnCount: returns.length,
}));
return returns;
}
/**
* Fetches a specific remission return receipt by receipt and return IDs.
* Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
*
*
* @async
* @param {FetchRemissionReturnParams} params - The receipt and return identifiers
* @param {FetchRemissionReturnParams} params.receiptId - ID of the receipt to fetch
@@ -159,7 +165,7 @@ export class RemissionReturnReceiptService {
* @returns {Promise<Receipt | undefined>} The receipt object if found, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
*
* @example
* const receipt = await service.fetchRemissionReturnReceipt({
* receiptId: '123',
@@ -171,15 +177,15 @@ export class RemissionReturnReceiptService {
abortSignal?: AbortSignal,
): Promise<Receipt | undefined> {
this.#logger.debug('Fetching remission return receipt', () => ({ params }));
const { receiptId, returnId } =
FetchRemissionReturnReceiptSchema.parse(params);
this.#logger.info('Fetching return receipt from API', () => ({
receiptId,
returnId
returnId,
}));
let req$ = this.#returnService.ReturnGetReturnReceipt({
receiptId,
returnId,
@@ -194,15 +200,18 @@ export class RemissionReturnReceiptService {
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch return receipt', new Error(res.message || 'Unknown error'));
this.#logger.error(
'Failed to fetch return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const receipt = res?.result as Receipt | undefined;
this.#logger.debug('Successfully fetched return receipt', () => ({
found: !!receipt
found: !!receipt,
}));
return receipt;
}
}

View File

@@ -0,0 +1,10 @@
@if (hasOldIncompleteRemissions() && !isLoading()) {
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<span class="mt-[2px]">
Die Remissionsliste wurde seit {{ daysThreshold() }} Tagen nicht bearbeitet
</span>
} @else if (error()) {
<button (click)="retry()" class="text-isa-accent-red hover:underline">
Fehler beim Laden. Erneut versuchen
</button>
}

View File

@@ -0,0 +1,8 @@
:host {
@apply flex items-start gap-1 justify-start;
@apply text-isa-accent-red isa-text-body-2-bold;
&:empty {
@apply hidden;
}
}

View File

@@ -0,0 +1,223 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HasPendingRemissionHintComponent } from './has-pending-remission-hint.component';
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import { subDays } from 'date-fns';
describe('HasPendingRemissionHintComponent', () => {
let component: HasPendingRemissionHintComponent;
let fixture: ComponentFixture<HasPendingRemissionHintComponent>;
let mockRemissionService: jest.Mocked<RemissionReturnReceiptService>;
beforeEach(async () => {
mockRemissionService = {
fetchIncompletedRemissionReturnReceipts: jest.fn(),
} as unknown as jest.Mocked<RemissionReturnReceiptService>;
await TestBed.configureTestingModule({
imports: [HasPendingRemissionHintComponent],
providers: [
{ provide: RemissionReturnReceiptService, useValue: mockRemissionService },
],
}).compileComponents();
fixture = TestBed.createComponent(HasPendingRemissionHintComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('Happy Path Cases', () => {
it('should show hint when there are incomplete remissions older than 7 days', async () => {
// Arrange
const oldRemission = {
id: '1',
created: subDays(new Date(), 10),
};
mockRemissionService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue([
oldRemission,
] as any);
// Act
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement;
expect(compiled.querySelector('ng-icon')).toBeTruthy();
expect(compiled.textContent).toContain(
'Die Remissionsliste wurde seit 7 Tagen nicht bearbeitet',
);
});
it('should not show hint when there are no incomplete remissions', async () => {
// Arrange
mockRemissionService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue(
[],
);
// Act
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement;
expect(compiled.querySelector('ng-icon')).toBeFalsy();
expect(compiled.textContent.trim()).toBe('');
});
it('should not show hint when all remissions are newer than 7 days', async () => {
// Arrange
const recentRemission = {
id: '1',
created: subDays(new Date(), 3),
};
mockRemissionService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue([
recentRemission,
] as any);
// Act
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement;
expect(compiled.querySelector('ng-icon')).toBeFalsy();
expect(compiled.textContent.trim()).toBe('');
});
it('should use custom days threshold when provided', async () => {
// Arrange
const remissionOlderThan5Days = {
id: '1',
created: subDays(new Date(), 6),
};
mockRemissionService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue([
remissionOlderThan5Days,
] as any);
// Act
fixture.componentRef.setInput('daysThreshold', 5);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement;
expect(compiled.querySelector('ng-icon')).toBeTruthy();
expect(compiled.textContent).toContain(
'Die Remissionsliste wurde seit 5 Tagen nicht bearbeitet',
);
});
it('should handle mix of old and new remissions correctly', async () => {
// Arrange
const remissions = [
{ id: '1', created: subDays(new Date(), 3) }, // Recent
{ id: '2', created: subDays(new Date(), 10) }, // Old
{ id: '3', created: subDays(new Date(), 5) }, // Recent
];
mockRemissionService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue(
remissions as any,
);
// Act
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// Assert
const compiled = fixture.nativeElement;
expect(compiled.querySelector('ng-icon')).toBeTruthy();
expect(compiled.textContent).toContain(
'Die Remissionsliste wurde seit 7 Tagen nicht bearbeitet',
);
});
it('should reload data when retry is called', async () => {
// Arrange - First call returns old remission
const oldRemission = {
id: '1',
created: subDays(new Date(), 10),
};
mockRemissionService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue([
oldRemission,
] as any);
// Act - Initial load
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// Assert - Should show hint
expect(fixture.nativeElement.querySelector('ng-icon')).toBeTruthy();
// Arrange - Setup for retry to return empty array
mockRemissionService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue(
[],
);
// Act - Call retry
component.retry();
await fixture.whenStable();
fixture.detectChanges();
// Assert - Should not show hint after retry
expect(fixture.nativeElement.querySelector('ng-icon')).toBeFalsy();
expect(
mockRemissionService.fetchIncompletedRemissionReturnReceipts,
).toHaveBeenCalledTimes(2);
});
it('should show hint for remissions exactly at threshold', async () => {
// Arrange - Remission exactly 7 days old
const remissionAtThreshold = {
id: '1',
created: subDays(new Date(), 7),
};
mockRemissionService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue([
remissionAtThreshold,
] as any);
// Act
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// Assert - Should show hint (7 days ago is considered old)
const compiled = fixture.nativeElement;
expect(compiled.querySelector('ng-icon')).toBeTruthy();
expect(compiled.textContent).toContain(
'Die Remissionsliste wurde seit 7 Tagen nicht bearbeitet',
);
});
it('should handle remissions without created date gracefully', async () => {
// Arrange
const remissions = [
{ id: '1', created: null },
{ id: '2', created: undefined },
{ id: '3', created: subDays(new Date(), 10) },
];
mockRemissionService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue(
remissions as any,
);
// Act
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// Assert - Should still show hint due to the one old remission
const compiled = fixture.nativeElement;
expect(compiled.querySelector('ng-icon')).toBeTruthy();
expect(compiled.textContent).toContain(
'Die Remissionsliste wurde seit 7 Tagen nicht bearbeitet',
);
});
});
});

View File

@@ -0,0 +1,79 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
resource,
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import { isAfter, subDays } from 'date-fns';
@Component({
selector: 'remi-has-pending-remission-hint',
templateUrl: './has-pending-remission-hint.component.html',
styleUrls: ['./has-pending-remission-hint.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIcon],
providers: [provideIcons({ isaOtherInfo })],
host: {
class: 'remi-has-pending-remission-hint',
},
})
export class HasPendingRemissionHintComponent {
readonly #remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/**
* Number of days after which remissions are considered old
* @default 7
*/
readonly daysThreshold = input<number>(7);
/**
* Resource that fetches incomplete remission return receipts
*/
readonly #incompleteRemissionsResource = resource({
loader: ({ abortSignal }) =>
this.#remissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts(
abortSignal,
),
});
/**
* Computed signal that determines if there are incomplete remissions older than the threshold
*/
readonly hasOldIncompleteRemissions = computed(() => {
const remissions = this.#incompleteRemissionsResource.value();
const threshold = this.daysThreshold();
if (!remissions || remissions.length === 0) {
return false;
}
const thresholdDate = subDays(new Date(), threshold);
return remissions.some((remission) => {
if (!remission.created) return false;
return isAfter(thresholdDate, remission.created);
});
});
/**
* Loading state of the resource
*/
readonly isLoading = this.#incompleteRemissionsResource.isLoading;
/**
* Error state of the resource
*/
readonly error = this.#incompleteRemissionsResource.error;
/**
* Retry method to refetch data in case of error
*/
readonly retry = () => {
this.#incompleteRemissionsResource.reload();
};
}

View File

@@ -1,3 +1,4 @@
<remi-has-pending-remission-hint></remi-has-pending-remission-hint>
<remission-feature-remission-start-card></remission-feature-remission-start-card>
<remi-feature-remission-list-select></remi-feature-remission-list-select>

View File

@@ -1,191 +1,193 @@
import {
ChangeDetectionStrategy,
Component,
inject,
computed,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import {
provideFilter,
withQuerySettingsFactory,
withQueryParamsSync,
FilterControlsPanelComponent,
FilterService,
} from '@isa/shared/filter';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import { RemissionStartCardComponent } from './remission-start-card/remission-start-card.component';
import { RemissionListSelectComponent } from './remission-list-select/remission-list-select.component';
import { toSignal } from '@angular/core/rxjs-interop';
import {
createRemissionInStockResource,
createRemissionListResource,
} from './resources';
import { injectRemissionListType } from './injects/inject-remission-list-type';
import { RemissionListItemComponent } from './remission-list-item/remission-list-item.component';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
ReturnItem,
StockInfo,
ReturnSuggestion,
} from '@isa/remission/data-access';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
}
/**
* RemissionListComponent
*
* Displays and manages a list of remission items with filtering and stock information.
* Implements local state using Angular signals and computed properties.
* Follows SOLID and Clean Code principles for maintainability and testability.
*
* @remarks
* - Uses OnPush change detection for performance.
* - All state is managed locally via signals.
* - Filtering is handled via FilterService.
* - Stock information is dynamically loaded for visible items.
*
* @see {@link https://angular.dev/style-guide} for Angular best practices.
*/
@Component({
selector: 'remission-feature-remission-list',
templateUrl: './remission-list.component.html',
styleUrl: './remission-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
provideFilter(
withQuerySettingsFactory(querySettingsFactory),
withQueryParamsSync(),
),
],
imports: [
RemissionStartCardComponent,
FilterControlsPanelComponent,
RemissionListSelectComponent,
RemissionListItemComponent,
RouterLink,
IconButtonComponent,
],
host: {
'[class]':
'"w-full flex flex-col gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden"',
},
})
export class RemissionListComponent {
/**
* Activated route instance for accessing route data and params.
*/
route = inject(ActivatedRoute);
/**
* Signal for the current route URL segments.
*/
routeUrl = toSignal(this.route.url);
/**
* FilterService instance for managing filter state and queries.
* @private
*/
#filterService = inject(FilterService);
/**
* Restores scroll position when navigating back to this component.
*/
restoreScrollPosition = injectRestoreScrollPosition();
/**
* Signal containing the current route data snapshot.
*/
routeData = toSignal(this.route.data);
/**
* Signal representing the currently selected remission list type.
*/
selectedRemissionListType = injectRemissionListType();
/**
* Resource signal for fetching the remission list based on current filters.
* @returns Remission list resource state.
*/
remissionResource = createRemissionListResource(() => {
return {
remissionListType: this.selectedRemissionListType(),
queryToken: this.#filterService.query(),
};
});
// TODO (Info): Bei Add Item und
// Bei remittieren eines Stapels die StockInformation für alle anderen Stapel mit der selben EAN
// Muss InStock nochmal aufgerufen werden um die StockInformationen zu aktualisieren
/**
* Resource signal for fetching stock information for the current remission items.
* Updates when the list of items changes.
* @returns Stock info resource state.
*/
inStockResource = createRemissionInStockResource(() => {
return {
itemIds: this.items()
.map((item) => item?.product?.catalogProductNumber)
.filter(
(catalogProductNumber): catalogProductNumber is string =>
typeof catalogProductNumber === 'string',
),
};
});
/**
* Computed signal for the current remission list response.
* @returns The latest remission list response or undefined.
*/
listResponseValue = computed(() => this.remissionResource.value());
/**
* Computed signal for the current in-stock response.
* @returns Array of StockInfo or undefined.
*/
inStockResponseValue = computed(() => this.inStockResource.value());
/**
* Computed signal for the remission items to display.
* @returns Array of ReturnItem or ReturnSuggestion.
*/
items = computed(() => {
const value = this.listResponseValue();
return value?.result ? value.result : [];
});
/**
* Computed signal for the total number of hits in the remission list.
* @returns Number of hits, or 0 if unavailable.
*/
hits = computed(() => {
const value = this.listResponseValue();
return value?.hits ? value.hits : 0;
});
/**
* Computed signal mapping item IDs to their StockInfo.
* @returns Map of itemId to StockInfo.
*/
stockInfoMap = computed(() => {
const infos = this.inStockResponseValue() ?? [];
return new Map(infos.map((info) => [info.itemId, info]));
});
/**
* Commits the current filter state and triggers a new search.
*/
search(): void {
this.#filterService.commit();
}
/**
* Retrieves the StockInfo for a given item.
* @param item - The ReturnItem or ReturnSuggestion to look up.
* @returns The StockInfo for the item, or undefined if not found.
*/
getStockForItem(item: ReturnItem | ReturnSuggestion): StockInfo | undefined {
return this.stockInfoMap().get(Number(item?.product?.catalogProductNumber));
}
}
import {
ChangeDetectionStrategy,
Component,
inject,
computed,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import {
provideFilter,
withQuerySettingsFactory,
withQueryParamsSync,
FilterControlsPanelComponent,
FilterService,
} from '@isa/shared/filter';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import { RemissionStartCardComponent } from './remission-start-card/remission-start-card.component';
import { RemissionListSelectComponent } from './remission-list-select/remission-list-select.component';
import { toSignal } from '@angular/core/rxjs-interop';
import {
createRemissionInStockResource,
createRemissionListResource,
} from './resources';
import { injectRemissionListType } from './injects/inject-remission-list-type';
import { RemissionListItemComponent } from './remission-list-item/remission-list-item.component';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
ReturnItem,
StockInfo,
ReturnSuggestion,
} from '@isa/remission/data-access';
import { HasPendingRemissionHintComponent } from './has-pending-remission-hint/has-pending-remission-hint.component';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
}
/**
* RemissionListComponent
*
* Displays and manages a list of remission items with filtering and stock information.
* Implements local state using Angular signals and computed properties.
* Follows SOLID and Clean Code principles for maintainability and testability.
*
* @remarks
* - Uses OnPush change detection for performance.
* - All state is managed locally via signals.
* - Filtering is handled via FilterService.
* - Stock information is dynamically loaded for visible items.
*
* @see {@link https://angular.dev/style-guide} for Angular best practices.
*/
@Component({
selector: 'remission-feature-remission-list',
templateUrl: './remission-list.component.html',
styleUrl: './remission-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
provideFilter(
withQuerySettingsFactory(querySettingsFactory),
withQueryParamsSync(),
),
],
imports: [
RemissionStartCardComponent,
FilterControlsPanelComponent,
RemissionListSelectComponent,
RemissionListItemComponent,
RouterLink,
IconButtonComponent,
HasPendingRemissionHintComponent,
],
host: {
'[class]':
'"w-full flex flex-col gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden"',
},
})
export class RemissionListComponent {
/**
* Activated route instance for accessing route data and params.
*/
route = inject(ActivatedRoute);
/**
* Signal for the current route URL segments.
*/
routeUrl = toSignal(this.route.url);
/**
* FilterService instance for managing filter state and queries.
* @private
*/
#filterService = inject(FilterService);
/**
* Restores scroll position when navigating back to this component.
*/
restoreScrollPosition = injectRestoreScrollPosition();
/**
* Signal containing the current route data snapshot.
*/
routeData = toSignal(this.route.data);
/**
* Signal representing the currently selected remission list type.
*/
selectedRemissionListType = injectRemissionListType();
/**
* Resource signal for fetching the remission list based on current filters.
* @returns Remission list resource state.
*/
remissionResource = createRemissionListResource(() => {
return {
remissionListType: this.selectedRemissionListType(),
queryToken: this.#filterService.query(),
};
});
// TODO (Info): Bei Add Item und
// Bei remittieren eines Stapels die StockInformation für alle anderen Stapel mit der selben EAN
// Muss InStock nochmal aufgerufen werden um die StockInformationen zu aktualisieren
/**
* Resource signal for fetching stock information for the current remission items.
* Updates when the list of items changes.
* @returns Stock info resource state.
*/
inStockResource = createRemissionInStockResource(() => {
return {
itemIds: this.items()
.map((item) => item?.product?.catalogProductNumber)
.filter(
(catalogProductNumber): catalogProductNumber is string =>
typeof catalogProductNumber === 'string',
),
};
});
/**
* Computed signal for the current remission list response.
* @returns The latest remission list response or undefined.
*/
listResponseValue = computed(() => this.remissionResource.value());
/**
* Computed signal for the current in-stock response.
* @returns Array of StockInfo or undefined.
*/
inStockResponseValue = computed(() => this.inStockResource.value());
/**
* Computed signal for the remission items to display.
* @returns Array of ReturnItem or ReturnSuggestion.
*/
items = computed(() => {
const value = this.listResponseValue();
return value?.result ? value.result : [];
});
/**
* Computed signal for the total number of hits in the remission list.
* @returns Number of hits, or 0 if unavailable.
*/
hits = computed(() => {
const value = this.listResponseValue();
return value?.hits ? value.hits : 0;
});
/**
* Computed signal mapping item IDs to their StockInfo.
* @returns Map of itemId to StockInfo.
*/
stockInfoMap = computed(() => {
const infos = this.inStockResponseValue() ?? [];
return new Map(infos.map((info) => [info.itemId, info]));
});
/**
* Commits the current filter state and triggers a new search.
*/
search(): void {
this.#filterService.commit();
}
/**
* Retrieves the StockInfo for a given item.
* @param item - The ReturnItem or ReturnSuggestion to look up.
* @returns The StockInfo for the item, or undefined if not found.
*/
getStockForItem(item: ReturnItem | ReturnSuggestion): StockInfo | undefined {
return this.stockInfoMap().get(Number(item?.product?.catalogProductNumber));
}
}

View File

@@ -1,33 +1,33 @@
import { QuerySettings } from '@isa/shared/filter';
/**
* Query settings configuration for filtering and sorting return receipts.
* Provides options to sort by date in ascending or descending order.
* @constant
*/
export const RETURN_RECEIPT_QUERY_SETTINGS: QuerySettings = {
filter: [],
input: [],
orderBy: [
// {
// by: 'completed',
// label: 'Remissiondatum',
// desc: true,
// },
// {
// by: 'completed',
// label: 'Remissiondatum',
// desc: false,
// },
{
by: 'created',
label: 'Datum',
desc: true,
},
{
by: 'created',
label: 'Datum',
desc: false,
},
],
};
import { QuerySettings } from '@isa/shared/filter';
/**
* Query settings configuration for filtering and sorting return receipts.
* Provides options to sort by date in ascending or descending order.
* @constant
*/
export const RETURN_RECEIPT_QUERY_SETTINGS: QuerySettings = {
filter: [],
input: [],
orderBy: [
// {
// by: 'completed',
// label: 'Remissiondatum',
// desc: true,
// },
// {
// by: 'completed',
// label: 'Remissiondatum',
// desc: false,
// },
{
by: 'created',
label: 'Datum',
desc: true,
},
{
by: 'created',
label: 'Datum',
desc: false,
},
],
};