feat(remission): enhance has-pending-remission-hint component with dynamic days threshold and loading/error handling

This commit is contained in:
Lorenz Hilpert
2025-07-11 16:09:40 +02:00
parent 741e1e6d15
commit 9623a8ede5
9 changed files with 339 additions and 110 deletions

View File

@@ -118,14 +118,14 @@ export class RemissionReturnReceiptService {
this.#logger.info('Fetching incomplete returns from API', () => ({
stockId: assignedStock.id,
startDate: subDays(new Date(), 30).toISOString(),
startDate: subDays(new Date(), 7).toISOString(),
}));
let req$ = this.#returnService.ReturnQueryReturns({
stockId: assignedStock.id,
queryToken: {
input: { returncompleted: 'false' },
start: subDays(new Date(), 30).toISOString(),
start: subDays(new Date(), 7).toISOString(),
eagerLoading: 3,
},
});

View File

@@ -1,4 +1,10 @@
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<span class="mt-[2px]"
>Die Remissionsliste wurde seit 7 Tagen nicht bearbeitet</span
>
@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

@@ -1,4 +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

@@ -1,6 +1,15 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
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',
@@ -11,7 +20,60 @@ import { isaOtherInfo } from '@isa/icons';
imports: [NgIcon],
providers: [provideIcons({ isaOtherInfo })],
host: {
class: 'remi-has-pending-remission-hint ',
class: 'remi-has-pending-remission-hint',
},
})
export class HasPendingRemissionHintComponent {}
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,62 +0,0 @@
import {
ComponentRef,
computed,
Directive,
effect,
EmbeddedViewRef,
inject,
resource,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import { isAfter, subDays } from 'date-fns';
import { HasPendingRemissionHintComponent } from './has-pending-remission-hint.component';
@Directive({ selector: '[remiHasPendingRemissionHint]' })
export class HasPendingRemissionHintDirective {
#viewContainerRef = inject(ViewContainerRef);
#templateRef = inject(TemplateRef);
#embeddedViewRef: EmbeddedViewRef<unknown> | null = null;
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
incompleteRemissionResource = resource({
loader: ({ abortSignal }) =>
this.#remissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts(
abortSignal,
),
});
hasIncompletedRemissionsOlderThan7Days = computed(() => {
const incompleteRemissions = this.incompleteRemissionResource.value();
if (!incompleteRemissions) return false;
return incompleteRemissions.some((remission) =>
isAfter(subDays(new Date(), 7), remission.created!),
);
});
constructor() {
effect(() => this.render());
}
render() {
const hasPendingRemission = this.hasIncompletedRemissionsOlderThan7Days();
if (hasPendingRemission && !this.#embeddedViewRef) {
this.#embeddedViewRef = this.#viewContainerRef.createEmbeddedView(
this.#templateRef,
);
const componentRef: ComponentRef<HasPendingRemissionHintComponent> = this
.#embeddedViewRef
.rootNodes[0] as ComponentRef<HasPendingRemissionHintComponent>;
componentRef.changeDetectorRef.detectChanges();
} else if (!hasPendingRemission && this.#embeddedViewRef) {
this.#viewContainerRef.clear();
this.#embeddedViewRef = null;
}
}
}

View File

@@ -1,6 +1,4 @@
<remi-has-pending-remission-hint
*remiHasPendingRemissionHint
></remi-has-pending-remission-hint>
<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

@@ -28,7 +28,6 @@ import {
StockInfo,
ReturnSuggestion,
} from '@isa/remission/data-access';
import { HasPendingRemissionHintDirective } from './has-pending-remission-hint/has-pending-remission-hint.directive';
import { HasPendingRemissionHintComponent } from './has-pending-remission-hint/has-pending-remission-hint.component';
function querySettingsFactory() {
@@ -68,7 +67,6 @@ function querySettingsFactory() {
RemissionListItemComponent,
RouterLink,
IconButtonComponent,
HasPendingRemissionHintDirective,
HasPendingRemissionHintComponent,
],
host: {

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,
},
],
};