Merged PR 1913: feat(remission): refactor return receipt details and extract shared actions

feat(remission): refactor return receipt details and extract shared actions

Refactor remission return receipt details to use return-based data flow
instead of individual receipt fetching. Extract reusable action components
for better code organization and consistency.

- Remove deprecated fetchRemissionReturnReceipt method and schema
- Add helper functions for extracting data from return objects
- Replace receipt-specific components with return-based equivalents
- Create shared return-receipt-actions library with reusable components
- Update components to use modern Angular patterns (signals, computed)
- Improve data flow consistency across remission features
- Add comprehensive test coverage for new components
- Update eager loading support in fetch return functionality

The new architecture provides better data consistency and reduces
code duplication by centralizing receipt actions and data extraction
logic into reusable components.

Refs: #5242, #5138, #5232, #5241
This commit is contained in:
Nino Righi
2025-08-12 13:32:57 +00:00
committed by Andreas Schickinger
parent 2e012a124a
commit 99e8e7cfe0
77 changed files with 3294 additions and 3039 deletions

View File

@@ -4,16 +4,14 @@ import { firstValueFrom } from 'rxjs';
import { injectTabId } from '@isa/core/tabs';
import { ReturnTaskListStore } from '@isa/oms/data-access';
import { ReturnReviewComponent } from '../return-review.component';
import { ConfirmationDialogComponent, injectDialog } from '@isa/ui/dialog';
import { injectConfirmationDialog } from '@isa/ui/dialog';
@Injectable({ providedIn: 'root' })
export class UncompletedTasksGuard
implements CanDeactivate<ReturnReviewComponent>
{
#returnTaskListStore = inject(ReturnTaskListStore);
#confirmationDialog = injectDialog(ConfirmationDialogComponent, {
title: 'Aufgaben erledigen',
});
#confirmationDialog = injectConfirmationDialog();
processId = injectTabId();
@@ -45,6 +43,7 @@ export class UncompletedTasksGuard
async openDialog(): Promise<boolean> {
const confirmDialogRef = this.#confirmationDialog({
title: 'Aufgaben erledigen',
data: {
message:
'Bitte schließen Sie die Aufgaben ab bevor Sie das die Rückgabe verlassen',

View File

@@ -0,0 +1,37 @@
import { Return } from '../models';
/**
* Extracts all package numbers from all receipts in a return.
* Only includes package numbers from receipts that have loaded data and where the package data exists.
*
* @param returnData - The return object containing receipts
* @returns Comma-separated string of all package numbers from all receipts, or empty string if no packages found
*
* @example
* ```typescript
* const packageNumbers = getPackageNumbersFromReturn(returnData);
* console.log(`Package numbers: ${packageNumbers}`); // "PKG-001, PKG-002, PKG-003"
* ```
*/
export const getPackageNumbersFromReturn = (returnData: Return): string => {
if (!returnData?.receipts || returnData.receipts.length === 0) {
return '';
}
const allPackageNumbers = returnData.receipts.reduce<string[]>(
(packageNumbers, receipt) => {
const receiptPackages = receipt.data?.packages || [];
// Extract package numbers from loaded packages, filtering out packages without data or packageNumber
const receiptPackageNumbers = receiptPackages
.filter((pkg) => pkg.data?.packageNumber)
.map((pkg) => pkg.data!.packageNumber!);
packageNumbers.push(...receiptPackageNumbers);
return packageNumbers;
},
[],
);
return allPackageNumbers.join(', ');
};

View File

@@ -0,0 +1,20 @@
import { Return } from '../models';
/**
* Helper function to calculate the total item quantity from all receipts in a return.
* If no receipts are present, returns 0.
* @param {Return} returnData - The return object containing receipts
* @return {number} Total item quantity from all receipts
*/
export const getReceiptItemQuantityFromReturn = (
returnData: Return,
): number => {
if (!returnData?.receipts || returnData.receipts.length === 0) {
return 0;
}
return returnData.receipts.reduce((totalItems, receipt) => {
const items = receipt.data?.items;
return totalItems + (items ? items.length : 0);
}, 0);
};

View File

@@ -0,0 +1,35 @@
import { Return } from '../models';
import { ReceiptItem } from '../models';
/**
* Extracts all receipt item data from all receipts in a return.
* Only includes items from receipts that have loaded data and where the item data exists.
*
* @param returnData - The return object containing receipts
* @returns Array of all receipt item data from all receipts, or empty array if no items found
*
* @example
* ```typescript
* const items = getReceiptItemsFromReturn(returnData);
* console.log(`Found ${items.length} receipt items across all receipts`);
* ```
*/
export const getReceiptItemsFromReturn = (
returnData: Return,
): ReceiptItem[] => {
if (!returnData?.receipts || returnData.receipts.length === 0) {
return [];
}
return returnData.receipts.reduce<ReceiptItem[]>((items, receipt) => {
const receiptItems = receipt.data?.items || [];
// Extract only the actual ReceiptItem data, filtering out items without data
const itemData = receiptItems
.filter((item) => item.data !== undefined)
.map((item) => item.data!);
items.push(...itemData);
return items;
}, []);
};

View File

@@ -0,0 +1,21 @@
import { Return } from '../models';
/**
* Helper function to extract and format receipt numbers from a return object.
* Returns "Keine Belege vorhanden" if no receipts, otherwise returns formatted receipt numbers.
*
* @param {Return} returnData - The return object containing receipts
* @returns {string} The formatted receipt numbers or message
*/
export const getReceiptNumberFromReturn = (returnData: Return): string => {
if (!returnData?.receipts || returnData.receipts.length === 0) {
return 'Keine Belege vorhanden';
}
const receiptNumbers = returnData.receipts
.map((receipt) => receipt.data?.receiptNumber)
.filter((receiptNumber) => receiptNumber && receiptNumber.length >= 12)
.map((receiptNumber) => receiptNumber!.substring(6, 12));
return receiptNumbers.length > 0 ? receiptNumbers.join(', ') : '';
};

View File

@@ -0,0 +1,73 @@
import { getReceiptStatusFromReturn } from './get-receipt-status-from-return.helper';
import { ReceiptCompleteStatus, Return } from '../models';
describe('getReceiptStatusFromReturn', () => {
it('should return Offen when no receipts exist', () => {
// Arrange
const returnData: Return = {
receipts: [] as any,
} as Return;
// Act
const result = getReceiptStatusFromReturn(returnData);
// Assert
expect(result).toBe(ReceiptCompleteStatus.Offen);
});
it('should return Offen when receipts array is undefined', () => {
// Arrange
const returnData: Return = {} as Return;
// Act
const result = getReceiptStatusFromReturn(returnData);
// Assert
expect(result).toBe(ReceiptCompleteStatus.Offen);
});
it('should return Abgeschlossen when at least one receipt is completed', () => {
// Arrange
const returnData: Return = {
receipts: [
{ data: { completed: 'Offen' } },
{ data: { completed: 'Abgeschlossen' } },
],
} as Return;
// Act
const result = getReceiptStatusFromReturn(returnData);
// Assert
expect(result).toBe(ReceiptCompleteStatus.Abgeschlossen);
});
it('should return Abgeschlossen when all receipts are incomplete', () => {
// Arrange
const returnData: Return = {
receipts: [
{ data: { completed: 'Abgeschlossen' } },
{ data: { completed: 'Abgeschlossen' } },
],
} as Return;
// Act
const result = getReceiptStatusFromReturn(returnData);
// Assert
expect(result).toBe(ReceiptCompleteStatus.Abgeschlossen);
});
it('should return Offen when receipt data is undefined', () => {
// Arrange
const returnData: Return = {
receipts: [{ data: undefined }, {}],
} as Return;
// Act
const result = getReceiptStatusFromReturn(returnData);
// Assert
expect(result).toBe(ReceiptCompleteStatus.Offen);
});
});

View File

@@ -0,0 +1,28 @@
import {
ReceiptCompleteStatus,
ReceiptCompleteStatusValue,
Return,
} from '../models';
/**
* Helper function to determine the receipt status from a return object.
* Returns 'Offen' if no receipts or all are incomplete, otherwise returns 'Abgeschlossen'.
*
* @param {Return} returnData - The return object containing receipts
* @returns {ReceiptCompleteStatusValue} The completion status of the return
*/
export const getReceiptStatusFromReturn = (
returnData: Return,
): ReceiptCompleteStatusValue => {
if (!returnData?.receipts || returnData.receipts.length === 0) {
return ReceiptCompleteStatus.Offen;
}
const hasCompletedReceipt = returnData.receipts.some(
(receipt) => receipt.data?.completed,
);
return hasCompletedReceipt
? ReceiptCompleteStatus.Abgeschlossen
: ReceiptCompleteStatus.Offen;
};

View File

@@ -2,3 +2,8 @@ export * from './calc-available-stock.helper';
export * from './calc-stock-to-remit.helper';
export * from './calc-target-stock.helper';
export * from './calc-capacity.helper';
export * from './get-receipt-status-from-return.helper';
export * from './get-receipt-item-quantity-from-return.helper';
export * from './get-receipt-number-from-return.helper';
export * from './get-receipt-items-from-return.helper';
export * from './get-package-numbers-from-return.helper';

View File

@@ -17,3 +17,4 @@ export * from './receipt-return-suggestion-tuple';
export * from './value-tuple-sting-and-integer';
export * from './create-remission';
export * from './remission-item-source';
export * from './receipt-complete-status';

View File

@@ -0,0 +1,8 @@
export const ReceiptCompleteStatus = {
Offen: 'Offen',
Abgeschlossen: 'Abgeschlossen',
} as const;
export type ReceiptCompleteStatusKey = keyof typeof ReceiptCompleteStatus;
export type ReceiptCompleteStatusValue =
(typeof ReceiptCompleteStatus)[ReceiptCompleteStatusKey];

View File

@@ -1,51 +0,0 @@
import { z } from 'zod';
/**
* Zod schema for validating remission return receipt fetch parameters.
* Ensures both receiptId and returnId are valid numbers.
*
* @constant
* @type {z.ZodObject}
*
* @example
* const params = FetchRemissionReturnReceiptSchema.parse({
* receiptId: '123',
* returnId: '456'
* });
* // Result: { receiptId: 123, returnId: 456 }
*/
export const FetchRemissionReturnReceiptSchema = z.object({
/**
* The receipt identifier - coerced to number for flexibility.
*/
receiptId: z.coerce.number(),
/**
* The return identifier - coerced to number for flexibility.
*/
returnId: z.coerce.number(),
});
/**
* Type representing the parsed output of FetchRemissionReturnReceiptSchema.
* Contains validated and coerced receiptId and returnId as numbers.
*
* @typedef {Object} FetchRemissionReturnReceipt
* @property {number} receiptId - The validated receipt identifier
* @property {number} returnId - The validated return identifier
*/
export type FetchRemissionReturnReceipt = z.infer<
typeof FetchRemissionReturnReceiptSchema
>;
/**
* Type representing the input parameters for FetchRemissionReturnReceiptSchema.
* Accepts string or number values that can be coerced to numbers.
*
* @typedef {Object} FetchRemissionReturnParams
* @property {string | number} receiptId - The receipt identifier (can be string or number)
* @property {string | number} returnId - The return identifier (can be string or number)
*/
export type FetchRemissionReturnParams = z.input<
typeof FetchRemissionReturnReceiptSchema
>;

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const FetchReturnSchema = z.object({
returnId: z.coerce.number(),
eagerLoading: z.coerce.number().optional(),
});
export type FetchReturn = z.infer<typeof FetchReturnSchema>;
export type FetchReturnParams = z.input<typeof FetchReturnSchema>;

View File

@@ -4,8 +4,8 @@ export * from './assign-package.schema';
export * from './create-receipt.schema';
export * from './create-return.schema';
export * from './fetch-query-settings.schema';
export * from './fetch-remission-return-receipt.schema';
export * from './fetch-remission-return-receipts.schema';
export * from './fetch-stock-in-stock.schema';
export * from './query-token.schema';
export * from './fetch-required-capacity.schema';
export * from './fetch-return.schema';

View File

@@ -29,7 +29,7 @@ describe('RemissionReturnReceiptService', () => {
let service: RemissionReturnReceiptService;
let mockReturnService: {
ReturnQueryReturns: jest.Mock;
ReturnGetReturnReceipt: jest.Mock;
ReturnGetReturn: jest.Mock;
ReturnCreateReturn: jest.Mock;
ReturnCreateReceipt: jest.Mock;
ReturnCreateAndAssignPackage: jest.Mock;
@@ -37,8 +37,11 @@ describe('RemissionReturnReceiptService', () => {
ReturnDeleteReturnItem: jest.Mock;
ReturnFinalizeReceipt: jest.Mock;
ReturnFinalizeReturn: jest.Mock;
ReturnFinalizeReturnGroup: jest.Mock;
ReturnAddReturnItem: jest.Mock;
ReturnAddReturnSuggestion: jest.Mock;
ReturnCancelReturn: jest.Mock;
ReturnCancelReturnReceipt: jest.Mock;
};
let mockRemissionStockService: {
fetchAssignedStock: jest.Mock;
@@ -89,7 +92,7 @@ describe('RemissionReturnReceiptService', () => {
beforeEach(() => {
mockReturnService = {
ReturnQueryReturns: jest.fn(),
ReturnGetReturnReceipt: jest.fn(),
ReturnGetReturn: jest.fn(),
ReturnCreateReturn: jest.fn(),
ReturnCreateReceipt: jest.fn(),
ReturnCreateAndAssignPackage: jest.fn(),
@@ -97,8 +100,11 @@ describe('RemissionReturnReceiptService', () => {
ReturnDeleteReturnItem: jest.fn(),
ReturnFinalizeReceipt: jest.fn(),
ReturnFinalizeReturn: jest.fn(),
ReturnFinalizeReturnGroup: jest.fn(),
ReturnAddReturnItem: jest.fn(),
ReturnAddReturnSuggestion: jest.fn(),
ReturnCancelReturn: jest.fn(),
ReturnCancelReturnReceipt: jest.fn(),
};
mockRemissionStockService = {
@@ -230,88 +236,116 @@ describe('RemissionReturnReceiptService', () => {
});
});
describe('fetchRemissionReturnReceipt', () => {
const mockReceipt: Receipt = {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt;
describe('fetchReturn', () => {
const mockReturn: Return = {
id: 123,
receipts: [
{
id: 101,
data: {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt,
},
],
} as unknown as Return;
beforeEach(() => {
mockReturnService.ReturnGetReturnReceipt = jest.fn();
mockReturnService.ReturnGetReturn = jest.fn();
});
it('should fetch return receipt successfully', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of({ result: mockReceipt, error: null }),
it('should fetch return successfully', async () => {
// Arrange
mockReturnService.ReturnGetReturn.mockReturnValue(
of({ result: mockReturn, error: null }),
);
const params = { receiptId: 101, returnId: 1 };
const result = await service.fetchRemissionReturnReceipt(params);
const params = { returnId: 123 };
expect(result).toEqual(mockReceipt);
expect(mockReturnService.ReturnGetReturnReceipt).toHaveBeenCalledWith({
receiptId: 101,
returnId: 1,
// Act
const result = await service.fetchReturn(params);
// Assert
expect(result).toEqual(mockReturn);
expect(mockReturnService.ReturnGetReturn).toHaveBeenCalledWith({
returnId: 123,
eagerLoading: 2,
});
});
it('should handle abort signal', async () => {
// Arrange
const abortController = new AbortController();
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of({ result: mockReceipt, error: null }),
mockReturnService.ReturnGetReturn.mockReturnValue(
of({ result: mockReturn, error: null }),
);
const params = { receiptId: 101, returnId: 1 };
await service.fetchRemissionReturnReceipt(params, abortController.signal);
const params = { returnId: 123 };
expect(mockReturnService.ReturnGetReturnReceipt).toHaveBeenCalled();
// Act
await service.fetchReturn(params, abortController.signal);
// Assert
expect(mockReturnService.ReturnGetReturn).toHaveBeenCalledWith({
returnId: 123,
eagerLoading: 2,
});
});
it('should throw ResponseArgsError when API returns error', async () => {
// Arrange
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of(errorResponse),
);
mockReturnService.ReturnGetReturn.mockReturnValue(of(errorResponse));
const params = { receiptId: 101, returnId: 1 };
await expect(service.fetchRemissionReturnReceipt(params)).rejects.toThrow(
const params = { returnId: 123 };
// Act & Assert
await expect(service.fetchReturn(params)).rejects.toThrow(
ResponseArgsError,
);
});
it('should return null when result is null', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
// Arrange
mockReturnService.ReturnGetReturn.mockReturnValue(
of({ result: null, error: null }),
);
const params = { receiptId: 101, returnId: 1 };
const result = await service.fetchRemissionReturnReceipt(params);
const params = { returnId: 123 };
// Act
const result = await service.fetchReturn(params);
// Assert
expect(result).toBeNull();
});
it('should return undefined when result is undefined', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of({ error: null }),
);
// Arrange
mockReturnService.ReturnGetReturn.mockReturnValue(of({ error: null }));
const params = { receiptId: 101, returnId: 1 };
const result = await service.fetchRemissionReturnReceipt(params);
const params = { returnId: 123 };
// Act
const result = await service.fetchReturn(params);
// Assert
expect(result).toBeUndefined();
});
it('should handle observable errors', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
// Arrange
mockReturnService.ReturnGetReturn.mockReturnValue(
throwError(() => new Error('Observable error')),
);
const params = { receiptId: 101, returnId: 1 };
await expect(service.fetchRemissionReturnReceipt(params)).rejects.toThrow(
const params = { returnId: 123 };
// Act & Assert
await expect(service.fetchReturn(params)).rejects.toThrow(
'Observable error',
);
});
@@ -764,6 +798,74 @@ describe('RemissionReturnReceiptService', () => {
});
});
describe('completeReturnGroup', () => {
const mockCompletedReturns: Return[] = [
{
id: 1,
receipts: [
{
id: 101,
data: {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt,
},
],
} as unknown as Return,
{
id: 2,
receipts: [
{
id: 102,
data: {
id: 102,
receiptNumber: 'REC-2024-002',
completed: '2024-01-15T11:30:00.000Z',
created: '2024-01-15T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as unknown as Return,
];
it('should complete return group successfully', async () => {
// Arrange
mockReturnService.ReturnFinalizeReturnGroup.mockReturnValue(
of({ result: mockCompletedReturns, error: null }),
);
const params = { returnGroup: 'group-123' };
// Act
const result = await service.completeReturnGroup(params);
// Assert
expect(result).toEqual(mockCompletedReturns);
expect(mockReturnService.ReturnFinalizeReturnGroup).toHaveBeenCalledWith(
params,
);
});
it('should throw ResponseArgsError when API returns error', async () => {
// Arrange
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnFinalizeReturnGroup.mockReturnValue(
of(errorResponse),
);
const params = { returnGroup: 'group-123' };
// Act & Assert
await expect(service.completeReturnGroup(params)).rejects.toThrow(
ResponseArgsError,
);
});
});
describe('completeReturnReceiptAndReturn', () => {
const mockCompletedReceipt: Receipt = {
id: 101,
@@ -1523,4 +1625,112 @@ describe('RemissionReturnReceiptService', () => {
});
});
});
describe('cancelReturn', () => {
beforeEach(() => {
mockReturnService.ReturnCancelReturn = jest.fn();
});
it('should cancel return successfully', async () => {
// Arrange
mockReturnService.ReturnCancelReturn.mockReturnValue(
of({ result: null, error: null }),
);
const params = { returnId: 123 };
// Act
await service.cancelReturn(params);
// Assert
expect(mockReturnService.ReturnCancelReturn).toHaveBeenCalledWith(params);
});
it('should throw ResponseArgsError when API returns error', async () => {
// Arrange
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnCancelReturn.mockReturnValue(of(errorResponse));
const params = { returnId: 123 };
// Act & Assert
await expect(service.cancelReturn(params)).rejects.toThrow(
ResponseArgsError,
);
});
it('should handle observable errors', async () => {
// Arrange
mockReturnService.ReturnCancelReturn.mockReturnValue(
throwError(() => new Error('Observable error')),
);
const params = { returnId: 123 };
// Act & Assert
await expect(service.cancelReturn(params)).rejects.toThrow(
'Observable error',
);
});
});
describe('cancelReturnReceipt', () => {
beforeEach(() => {
mockReturnService.ReturnCancelReturnReceipt = jest.fn();
});
it('should cancel return receipt successfully', async () => {
// Arrange
mockReturnService.ReturnCancelReturnReceipt.mockReturnValue(
of({ result: null, error: null }),
);
const params = {
returnId: 123,
receiptId: 456,
};
// Act
await service.cancelReturnReceipt(params);
// Assert
expect(mockReturnService.ReturnCancelReturnReceipt).toHaveBeenCalledWith(
params,
);
});
it('should throw ResponseArgsError when API returns error', async () => {
// Arrange
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnCancelReturnReceipt.mockReturnValue(
of(errorResponse),
);
const params = {
returnId: 123,
receiptId: 456,
};
// Act & Assert
await expect(service.cancelReturnReceipt(params)).rejects.toThrow(
ResponseArgsError,
);
});
it('should handle observable errors', async () => {
// Arrange
mockReturnService.ReturnCancelReturnReceipt.mockReturnValue(
throwError(() => new Error('Observable error')),
);
const params = {
returnId: 123,
receiptId: 456,
};
// Act & Assert
await expect(service.cancelReturnReceipt(params)).rejects.toThrow(
'Observable error',
);
});
});
});

View File

@@ -18,10 +18,10 @@ import {
CreateReceipt,
CreateReturn,
CreateReturnSchema,
FetchRemissionReturnParams,
FetchRemissionReturnReceiptSchema,
FetchRemissionReturnReceiptsParams,
FetchRemissionReturnReceiptsSchema,
FetchReturnParams,
FetchReturnSchema,
} from '../schemas';
import {
CreateRemission,
@@ -126,43 +126,97 @@ export class RemissionReturnReceiptService {
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
// * @param {FetchRemissionReturnParams} params.returnId - ID of the return containing the receipt
// * @param {AbortSignal} [abortSignal] - Optional signal to abort the request
// * @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',
// * returnId: '456'
// * });
// */
// async fetchRemissionReturnReceipt(
// params: FetchRemissionReturnParams,
// 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,
// }));
// let req$ = this.#returnService.ReturnGetReturnReceipt({
// receiptId,
// returnId,
// eagerLoading: 2,
// });
// if (abortSignal) {
// this.#logger.debug('Request configured with abort signal');
// req$ = req$.pipe(takeUntilAborted(abortSignal));
// }
// const res = await firstValueFrom(req$);
// if (res?.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,
// }));
// return receipt;
// }
/**
* Fetches a specific remission return receipt by receipt and return IDs.
* Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
* Fetches a remission return by its ID.
* Validates parameters using FetchReturnSchema before making the request.
*
* @async
* @param {FetchRemissionReturnParams} params - The receipt and return identifiers
* @param {FetchRemissionReturnParams} params.receiptId - ID of the receipt to fetch
* @param {FetchRemissionReturnParams} params.returnId - ID of the return containing the receipt
* @param {FetchReturnParams} params - The parameters for fetching the return
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Receipt | undefined>} The receipt object if found, undefined otherwise
* @returns {Promise<Return | undefined>} The return 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',
* returnId: '456'
* });
* const returnData = await service.fetchReturn({ returnId: 123 });
*/
async fetchRemissionReturnReceipt(
params: FetchRemissionReturnParams,
async fetchReturn(
params: FetchReturnParams,
abortSignal?: AbortSignal,
): Promise<Receipt | undefined> {
this.#logger.debug('Fetching remission return receipt', () => ({ params }));
): Promise<Return | undefined> {
this.#logger.debug('Fetching remission return', () => ({ params }));
const { receiptId, returnId } =
FetchRemissionReturnReceiptSchema.parse(params);
const { returnId, eagerLoading = 2 } = FetchReturnSchema.parse(params);
this.#logger.info('Fetching return receipt from API', () => ({
receiptId,
this.#logger.info('Fetching return from API', () => ({
returnId,
}));
let req$ = this.#returnService.ReturnGetReturnReceipt({
receiptId,
let req$ = this.#returnService.ReturnGetReturn({
returnId,
eagerLoading: 2,
eagerLoading,
});
if (abortSignal) {
@@ -174,18 +228,18 @@ export class RemissionReturnReceiptService {
if (res?.error) {
this.#logger.error(
'Failed to fetch return receipt',
'Failed to fetch return',
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,
const returnData = res?.result as Return | undefined;
this.#logger.debug('Successfully fetched return', () => ({
found: !!returnData,
}));
return receipt;
return returnData;
}
/**
@@ -417,6 +471,56 @@ export class RemissionReturnReceiptService {
}
}
/**
* Cancels a return receipt and the associated return.
* Validates parameters before making the request.
*
* @async
* @param {Object} params - The parameters for the cancellation
* @param {number} params.returnId - ID of the return to cancel
* @param {number} params.receiptId - ID of the receipt to cancel
* @return {Promise<void>} Resolves when the cancellation is successful
* @throws {ResponseArgsError} When the API request fails
*/
async cancelReturnReceipt(params: {
returnId: number;
receiptId: number;
}): Promise<void> {
const res = await firstValueFrom(
this.#returnService.ReturnCancelReturnReceipt(params),
);
if (res?.error) {
this.#logger.error(
'Failed to cancel return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
}
/**
* Completes a single return receipt and the associated return.
* Validates parameters before making the request.
*
* @async
* @returns {Promise<Return>} The completed return object
* @throws {ResponseArgsError} When the API request fails
*/
async cancelReturn(params: { returnId: number }): Promise<void> {
const res = await firstValueFrom(
this.#returnService.ReturnCancelReturn(params),
);
if (res?.error) {
this.#logger.error(
'Failed to cancel return',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
}
async deleteReturnItem(params: { itemId: number }) {
this.#logger.debug('Deleting return item', () => ({ params }));
const res = await firstValueFrom(
@@ -485,6 +589,30 @@ export class RemissionReturnReceiptService {
return res?.result as Return;
}
async completeReturnGroup(params: { returnGroup: string }) {
this.#logger.debug('Completing return group', () => ({
returnId: params.returnGroup,
}));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReturnGroup(params),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return group',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
this.#logger.info('Successfully completed return group', () => ({
returnId: params.returnGroup,
}));
return res?.result as Return[];
}
async completeReturnReceiptAndReturn(params: {
returnId: number;
receiptId: number;

View File

@@ -8,7 +8,7 @@ describe('RemissionStore', () => {
beforeEach(() => {
const mockRemissionReturnReceiptService = {
fetchRemissionReturnReceipt: jest.fn(),
fetchReturn: jest.fn(),
};
TestBed.configureTestingModule({

View File

@@ -72,52 +72,29 @@ export const RemissionStore = signalStore(
remissionReturnReceiptService = inject(RemissionReturnReceiptService),
) => ({
/**
* Private resource for fetching the current remission receipt.
*
* This resource automatically tracks changes to returnId and receiptId from the store
* and refetches the receipt data when either value changes. The resource returns
* undefined when either ID is not set, preventing unnecessary HTTP requests.
*
* The resource uses the injected RemissionReturnReceiptService to fetch receipt data
* and supports request cancellation via AbortSignal for proper cleanup.
*
* @private
* @returns A resource instance that manages the receipt data fetching lifecycle
*
* @example
* ```typescript
* // Access the resource through computed signals
* const receipt = computed(() => store._receiptResource.value());
* const status = computed(() => store._receiptResource.status());
* const error = computed(() => store._receiptResource.error());
*
* // Manually reload the resource
* store._receiptResource.reload();
* ```
*
* @see {@link https://angular.dev/guide/signals/resource} Angular Resource API documentation
* Resource for fetching the receipt data based on the current receiptId.
* This resource is automatically reloaded when the receiptId changes.
* @returnId is undefined, the resource will not fetch any data.
* @returnId is set, it fetches the receipt data from the service.
*/
_receiptResource: resource({
_fetchReturnResource: resource({
params: () => ({
returnId: store.returnId(),
receiptId: store.receiptId(),
}),
loader: async ({ params, abortSignal }) => {
const { receiptId, returnId } = params;
const { returnId } = params;
if (!receiptId || !returnId) {
if (!returnId) {
return undefined;
}
const receipt =
await remissionReturnReceiptService.fetchRemissionReturnReceipt(
{
returnId,
receiptId,
},
abortSignal,
);
return receipt;
const returnData = await remissionReturnReceiptService.fetchReturn(
{
returnId,
},
abortSignal,
);
return returnData;
},
}),
}),
@@ -126,7 +103,7 @@ export const RemissionStore = signalStore(
remissionStarted: computed(
() => store.returnId() !== undefined && store.receiptId() !== undefined,
),
receipt: computed(() => store._receiptResource.value()),
returnData: computed(() => store._fetchReturnResource.value()),
})),
withMethods((store) => ({
/**
@@ -158,15 +135,44 @@ export const RemissionStore = signalStore(
returnId,
receiptId,
});
store._receiptResource.reload();
store._fetchReturnResource.reload();
store.storeState();
},
/**
* Reloads the receipt resource.
* This method should be called when the receipt data needs to be refreshed.
* Reloads the return resource to fetch the latest data.
* This is useful when the return data might have changed and needs to be refreshed.
*
* @example
* ```typescript
* remissionStore.reloadReturn();
* ```
*/
reloadReceipt() {
store._receiptResource.reload();
reloadReturn() {
store._fetchReturnResource.reload();
},
/**
* Checks if the current remission matches the provided returnId and receiptId.
* This is useful for determining if the current remission is active in the context of a component.
*
* @param returnId - The return ID to check against the current remission
* @param receiptId - The receipt ID to check against the current remission
* @returns {boolean} True if the current remission matches the provided IDs, false otherwise
*
* @example
* ```typescript
* const isCurrent = remissionStore.isCurrentRemission(123, 456);
* ```
*/
isCurrentRemission({
returnId,
receiptId,
}: {
returnId: number | undefined;
receiptId: number | undefined;
}): boolean {
return store.returnId() === returnId && store.receiptId() === receiptId;
},
/**
@@ -273,15 +279,15 @@ export const RemissionStore = signalStore(
},
/**
* Resets the remission store to its initial state.
* Clears all selected items, quantities, and resets return/receipt IDs.
* Clears the remission store state, resetting all values to their initial state.
* This is useful for starting a new remission process or clearing the current state.
*
* @example
* ```typescript
* remissionStore.resetRemission();
* remissionStore.clearState();
* ```
*/
finishRemission() {
clearState() {
patchState(store, initialState);
store.storeState();
},

View File

@@ -333,7 +333,7 @@ export class RemissionListComponent {
onDeleteRemissionListItem(inProgress: boolean) {
this.deleteRemissionListItemInProgress.set(inProgress);
if (!inProgress) {
this.reloadListAndReceipt();
this.reloadListAndReturnData();
}
}
@@ -387,7 +387,7 @@ export class RemissionListComponent {
}
await this.remitItems();
}
this.reloadListAndReceipt();
this.reloadListAndReturnData();
this.searchTrigger.set('reload');
}
});
@@ -435,7 +435,7 @@ export class RemissionListComponent {
}
this.remitItemsState.set('success');
this.reloadListAndReceipt();
this.reloadListAndReturnData();
} catch (error) {
this.#logger.error('Failed to remit items', error);
this.remitItemsError.set(
@@ -451,11 +451,11 @@ export class RemissionListComponent {
}
/**
* Reloads the remission list and receipt data.
* Reloads the remission list and return data.
* This method is used to refresh the displayed data after changes.
*/
reloadListAndReceipt() {
reloadListAndReturnData() {
this.remissionResource.reload();
this.#store.reloadReceipt();
this.#store.reloadReturn();
}
}

View File

@@ -6,7 +6,11 @@ import {
effect,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { RemissionStore } from '@isa/remission/data-access';
import {
getReceiptItemQuantityFromReturn,
getReceiptNumberFromReturn,
RemissionStore,
} from '@isa/remission/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
@Component({
@@ -24,20 +28,20 @@ export class RemissionReturnCardComponent {
receiptId = computed(() => this.#remissionStore.receiptId());
receiptItemsCount = computed(() => {
const receipt = this.#remissionStore.receipt();
return receipt?.items?.length ?? 0;
const returnData = this.#remissionStore.returnData();
return getReceiptItemQuantityFromReturn(returnData!);
});
receiptNumber = computed(() => {
const receipt = this.#remissionStore.receipt();
return receipt?.receiptNumber?.substring(6, 12);
const returnData = this.#remissionStore.returnData();
return getReceiptNumberFromReturn(returnData!);
});
constructor() {
effect(() => {
this.returnId();
this.receiptId();
this.#remissionStore.reloadReceipt();
this.#remissionStore.reloadReturn();
});
}
}

View File

@@ -1,9 +1,6 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RemissionStore } from '@isa/remission/data-access';
import { RemissionStartDialogComponent } from '@isa/remission/shared/remission-start-dialog';
import { RemissionStartService } from '@isa/remission/shared/remission-start-dialog';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'remi-feature-remission-start-card',
@@ -13,24 +10,9 @@ import { firstValueFrom } from 'rxjs';
imports: [ButtonComponent],
})
export class RemissionStartCardComponent {
#remissionStartDialog = injectDialog(RemissionStartDialogComponent);
#remissionStore = inject(RemissionStore);
#remissionStartService = inject(RemissionStartService);
async startRemission() {
const remissionStartDialogRef = this.#remissionStartDialog({
data: { returnGroup: undefined },
classList: ['gap-0'],
width: '30rem',
});
const result = await firstValueFrom(remissionStartDialogRef.closed);
if (result) {
const { returnId, receiptId } = result;
this.#remissionStore.startRemission({
returnId,
receiptId,
});
}
await this.#remissionStartService.startRemission(undefined);
}
}

View File

@@ -1,16 +0,0 @@
<ui-stateful-button
[(state)]="state"
defaultContent="Remittieren"
successContent="Hinzugefügt"
errorContent="Konnte nicht hinzugefügt werden."
errorAction="Noch mal versuchen"
defaultWidth="10rem"
successWidth="20.375rem"
errorWidth="32rem"
[pending]="isLoading()"
color="brand"
size="large"
class="remit-button"
(clicked)="clickHandler()"
(action)="retryHandler()"
/>

View File

@@ -1 +0,0 @@
// Component now uses ui-stateful-button which handles all styling

View File

@@ -1,58 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
signal,
OnDestroy,
} from '@angular/core';
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
@Component({
selector: 'remi-remit-button',
templateUrl: './remit-button.component.html',
styleUrls: ['./remit-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [StatefulButtonComponent],
})
export class RemitButtonComponent implements OnDestroy {
state = signal<StatefulButtonState>('default');
isLoading = signal<boolean>(false);
private timer: ReturnType<typeof setTimeout> | null = null;
ngOnDestroy(): void {
this.clearTimer();
}
clickHandler() {
// Clear any existing timer to prevent multiple clicks from stacking
this.clearTimer();
this.isLoading.set(true);
this.timer = setTimeout(() => {
this.isLoading.set(false);
// Simulate an async operation, e.g., API call
const success = Math.random() > 0.5; // Randomly succeed or fail
if (success) {
this.state.set('success');
} else {
this.state.set('error');
}
}, 100); // Simulate async operation
}
retryHandler() {
this.isLoading.set(true);
this.timer = setTimeout(() => {
this.isLoading.set(false);
this.state.set('success');
}, 100);
}
private clearTimer(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}

View File

@@ -13,7 +13,7 @@
class="isa-text-body-1-bold"
*uiSkeletonLoader="loading(); height: '1.5rem'"
>
{{ positionCount() }}
{{ itemQuantity() }}
</div>
</div>
<div>

View File

@@ -1,304 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { Receipt, Supplier } from '@isa/remission/data-access';
// Mock the supplier resource
vi.mock('./resources', () => ({
createSupplierResource: vi.fn(() => ({
value: signal([]),
isLoading: signal(false),
error: signal(null),
})),
}));
describe('RemissionReturnReceiptDetailsCardComponent', () => {
let component: RemissionReturnReceiptDetailsCardComponent;
let fixture: ComponentFixture<RemissionReturnReceiptDetailsCardComponent>;
const mockSuppliers: Supplier[] = [
{
id: 123,
name: 'Test Supplier GmbH',
address: 'Test Street 1',
} as Supplier,
{
id: 456,
name: 'Another Supplier Ltd',
address: 'Another Street 2',
} as Supplier,
];
const mockReceipt: Receipt = {
id: 789,
receiptNumber: 'RR-2024-001234-ABC',
completed: true,
created: new Date('2024-01-15T10:30:00Z'),
supplier: {
id: 123,
name: 'Test Supplier GmbH',
},
items: [
{
id: 1,
data: {
id: 1,
quantity: 5,
product: { id: 1, name: 'Product 1' },
},
},
{
id: 2,
data: {
id: 2,
quantity: 3,
product: { id: 2, name: 'Product 2' },
},
},
],
packages: [
{
id: 1,
data: {
id: 1,
packageNumber: 'PKG-001',
},
},
{
id: 2,
data: {
id: 2,
packageNumber: 'PKG-002',
},
},
],
} as Receipt;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsCardComponent, DatePipe],
}).compileComponents();
fixture = TestBed.createComponent(RemissionReturnReceiptDetailsCardComponent);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default loading state', () => {
expect(component.loading()).toBe(true);
});
it('should accept receipt input', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
expect(component.receipt()).toEqual(mockReceipt);
});
it('should accept loading input', () => {
fixture.componentRef.setInput('loading', false);
expect(component.loading()).toBe(false);
});
});
describe('status computed signal', () => {
it('should return "Abgeschlossen" when receipt is completed', () => {
const completedReceipt = { ...mockReceipt, completed: true };
fixture.componentRef.setInput('receipt', completedReceipt);
expect(component.status()).toBe('Abgeschlossen');
});
it('should return "Offen" when receipt is not completed', () => {
const openReceipt = { ...mockReceipt, completed: false };
fixture.componentRef.setInput('receipt', openReceipt);
expect(component.status()).toBe('Offen');
});
it('should return "Offen" when no receipt provided', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.status()).toBe('Offen');
});
});
describe('positionCount computed signal', () => {
it('should return the number of items', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
// mockReceipt has 2 items
expect(component.positionCount()).toBe(2);
});
it('should return 0 when no items', () => {
const receiptWithoutItems = { ...mockReceipt, items: [] };
fixture.componentRef.setInput('receipt', receiptWithoutItems);
expect(component.positionCount()).toBe(0);
});
it('should return undefined when no receipt provided', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.positionCount()).toBeUndefined();
});
it('should count all items regardless of data', () => {
const receiptWithUndefinedItems = {
...mockReceipt,
items: [
{ id: 1, data: undefined },
{ id: 2, data: { id: 2, quantity: 5 } },
],
};
fixture.componentRef.setInput('receipt', receiptWithUndefinedItems);
expect(component.positionCount()).toBe(2);
});
});
describe('supplier computed signal', () => {
it('should return supplier name when found', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.supplier()).toBe('Test Supplier GmbH');
});
it('should return "Unbekannt" when supplier not found', () => {
const receiptWithUnknownSupplier = {
...mockReceipt,
supplier: { id: 999, name: 'Unknown' },
};
fixture.componentRef.setInput('receipt', receiptWithUnknownSupplier);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.supplier()).toBe('Unbekannt');
});
it('should return "Unbekannt" when no suppliers loaded', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
(component.supplierResource as any).value = signal([]);
expect(component.supplier()).toBe('Unbekannt');
});
it('should return "Unbekannt" when no receipt provided', () => {
fixture.componentRef.setInput('receipt', undefined);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.supplier()).toBe('Unbekannt');
});
});
describe('completedAt computed signal', () => {
it('should return created date', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
expect(component.completedAt()).toEqual(mockReceipt.created);
});
it('should return undefined when no receipt', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.completedAt()).toBeUndefined();
});
});
describe('remiDate computed signal', () => {
it('should return completed date when available', () => {
const completedDate = new Date('2024-01-20T15:45:00Z');
const receiptWithCompleted = {
...mockReceipt,
completed: completedDate,
created: new Date('2024-01-15T10:30:00Z'),
};
fixture.componentRef.setInput('receipt', receiptWithCompleted);
expect(component.remiDate()).toEqual(completedDate);
});
it('should return created date when completed date not available', () => {
const receiptWithoutCompleted = {
...mockReceipt,
completed: false,
};
fixture.componentRef.setInput('receipt', receiptWithoutCompleted);
expect(component.remiDate()).toEqual(mockReceipt.created);
});
it('should return undefined when no receipt', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.remiDate()).toBeUndefined();
});
});
describe('packageNumber computed signal', () => {
it('should return comma-separated package numbers', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
expect(component.packageNumber()).toBe('PKG-001, PKG-002');
});
it('should return empty string when no packages', () => {
const receiptWithoutPackages = { ...mockReceipt, packages: [] };
fixture.componentRef.setInput('receipt', receiptWithoutPackages);
expect(component.packageNumber()).toBe('');
});
it('should return empty string when no receipt', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.packageNumber()).toBe('');
});
it('should handle packages with undefined data', () => {
const receiptWithUndefinedPackages = {
...mockReceipt,
packages: [
{ id: 1, data: undefined },
{ id: 2, data: { id: 2, packageNumber: 'PKG-002' } },
],
};
fixture.componentRef.setInput('receipt', receiptWithUndefinedPackages);
// packageNumber maps undefined values, which join as ', PKG-002'
expect(component.packageNumber()).toBe(', PKG-002');
});
});
describe('Component reactivity', () => {
it('should update computed signals when receipt changes', () => {
// Initial receipt
fixture.componentRef.setInput('receipt', mockReceipt);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.status()).toBe('Abgeschlossen');
expect(component.positionCount()).toBe(2);
// Change receipt
const newReceipt = {
...mockReceipt,
completed: false,
items: [{ id: 1, data: { id: 1, quantity: 10 } }],
};
fixture.componentRef.setInput('receipt', newReceipt);
expect(component.status()).toBe('Offen');
expect(component.positionCount()).toBe(1);
});
it('should create supplier resource on initialization', () => {
expect(component.supplierResource).toBeDefined();
expect(component.supplierResource.value).toBeDefined();
});
});
});

View File

@@ -4,25 +4,19 @@ import {
Component,
computed,
input,
linkedSignal,
} from '@angular/core';
import { Receipt } from '@isa/remission/data-access';
import {
getPackageNumbersFromReturn,
getReceiptItemQuantityFromReturn,
getReceiptNumberFromReturn,
getReceiptStatusFromReturn,
ReceiptCompleteStatusValue,
Return,
} from '@isa/remission/data-access';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
import { createSupplierResource } from './resources';
/**
* Component that displays detailed information about a remission return receipt in a card format.
* Shows supplier information, status, dates, item counts, and package numbers.
*
* @component
* @selector remi-remission-return-receipt-details-card
* @standalone
*
* @example
* <remi-remission-return-receipt-details-card
* [receipt]="receiptData"
* [loading]="isLoading">
* </remi-remission-return-receipt-details-card>
*/
@Component({
selector: 'remi-remission-return-receipt-details-card',
templateUrl: './remission-return-receipt-details-card.component.html',
@@ -33,10 +27,10 @@ import { createSupplierResource } from './resources';
})
export class RemissionReturnReceiptDetailsCardComponent {
/**
* Input for the receipt data to display.
* Input for the return data to be displayed in the card.
* @input
*/
receipt = input<Receipt>();
return = input.required<Return>();
/**
* Input to control the loading state of the card.
@@ -50,63 +44,57 @@ export class RemissionReturnReceiptDetailsCardComponent {
*/
supplierResource = createSupplierResource();
/**
* Computed signal that determines the receipt status text.
* @returns {'Abgeschlossen' | 'Offen'} Status text based on completion state
*/
status = computed(() => {
return this.receipt()?.completed ? 'Abgeschlossen' : 'Offen';
firstReceipt = computed(() => {
const returnData = this.return();
return returnData?.receipts?.[0]?.data;
});
/**
* Computed signal that calculates the items in the receipt.
* @returns {number} Count of items in the receipt or 0 if not available
* Computed signal that retrieves the receipt number from the return data.
* Uses the helper function to get the receipt number.
* @returns {string} The receipt number from the return
*/
positionCount = computed(() => {
const receipt = this.receipt();
return receipt?.items?.length;
receiptNumber = computed(() => {
const returnData = this.return();
return getReceiptNumberFromReturn(returnData);
});
/**
* Computed signal that finds and returns the supplier name.
* @returns {string} Supplier name or 'Unbekannt' if not found
* Computed signal that calculates the total item quantity from all receipts in the return.
* Uses the helper function to get the quantity.
* @returns {number} The total item quantity from all receipts
*/
itemQuantity = computed(() => {
const returnData = this.return();
return getReceiptItemQuantityFromReturn(returnData);
});
/**
* Linked signal that determines the completion status of the return.
* Uses the helper function to get the status based on the return data.
* @returns {ReceiptCompleteStatusValue} The completion status of the return
*/
status = linkedSignal<ReceiptCompleteStatusValue>(() => {
const returnData = this.return();
return getReceiptStatusFromReturn(returnData);
});
remiDate = computed(() => {
const returnData = this.return();
return returnData?.completed || returnData?.created;
});
packageNumber = computed(() => {
const returnData = this.return();
return getPackageNumbersFromReturn(returnData);
});
supplier = computed(() => {
const receipt = this.receipt();
const receipt = this.firstReceipt();
const supplier = this.supplierResource.value();
return (
supplier?.find((s) => s.id === receipt?.supplier?.id)?.name || 'Unbekannt'
);
});
/**
* Computed signal for the receipt completion date.
* @returns {Date | undefined} The creation date of the receipt
*/
completedAt = computed(() => {
const receipt = this.receipt();
return receipt?.created;
});
/**
* Computed signal for the remission date.
* Prioritizes completed date over created date.
* @returns {Date | undefined} The remission date
*/
remiDate = computed(() => {
const receipt = this.receipt();
return receipt?.completed || receipt?.created;
});
/**
* Computed signal that concatenates all package numbers.
* @returns {string} Comma-separated list of package numbers
*/
packageNumber = computed(() => {
const receipt = this.receipt();
return (
receipt?.packages?.map((p) => p.data?.packageNumber).join(', ') || ''
);
});
}

View File

@@ -1,492 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MockComponent, MockDirective } from 'ng-mocks';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import {
ReceiptItem,
RemissionProductGroupService,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
describe('RemissionReturnReceiptDetailsItemComponent', () => {
let component: RemissionReturnReceiptDetailsItemComponent;
let fixture: ComponentFixture<RemissionReturnReceiptDetailsItemComponent>;
const mockReceiptItem: ReceiptItem = {
id: 1,
quantity: 5,
product: {
id: 123,
name: 'Test Product',
contributors: 'Test Author',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: '200 pages',
productGroup: 'BOOK',
},
} as ReceiptItem;
const mockProductGroups = [
{ key: 'BOOK', value: 'Books' },
{ key: 'MAGAZINE', value: 'Magazines' },
{ key: 'DVD', value: 'DVDs' },
];
const mockRemissionProductGroupService = {
fetchProductGroups: vi.fn().mockResolvedValue(mockProductGroups),
};
const mockRemissionReturnReceiptService = {
removeReturnItemFromReturnReceipt: vi.fn(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsItemComponent],
providers: [
{
provide: RemissionProductGroupService,
useValue: mockRemissionProductGroupService,
},
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionReturnReceiptService,
},
],
})
.overrideComponent(RemissionReturnReceiptDetailsItemComponent, {
remove: {
imports: [
ProductImageDirective,
ProductRouterLinkDirective,
ProductFormatComponent,
IconButtonComponent,
],
},
add: {
imports: [
MockDirective(ProductImageDirective),
MockDirective(ProductRouterLinkDirective),
MockComponent(ProductFormatComponent),
MockComponent(IconButtonComponent),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(
RemissionReturnReceiptDetailsItemComponent,
);
component = fixture.componentInstance;
});
afterEach(() => {
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt.mockClear();
});
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component).toBeTruthy();
});
it('should have required item input', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component.item()).toEqual(mockReceiptItem);
});
});
describe('Component with valid receipt item', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
});
it('should display receipt item data', () => {
expect(component.item()).toEqual(mockReceiptItem);
expect(component.item().id).toBe(1);
expect(component.item().quantity).toBe(5);
expect(component.item().product.name).toBe('Test Product');
});
it('should handle product information correctly', () => {
const item = component.item();
expect(item.product.name).toBe('Test Product');
expect(item.product.contributors).toBe('Test Author');
expect(item.product.ean).toBe('1234567890123');
expect(item.product.format).toBe('Hardcover');
expect(item.product.formatDetail).toBe('200 pages');
expect(item.product.productGroup).toBe('BOOK');
});
it('should handle quantity correctly', () => {
expect(component.item().quantity).toBe(5);
});
it('should have default removeable value', () => {
expect(component.removeable()).toBe(false);
});
});
describe('Component with different receipt item data', () => {
it('should handle different quantity values', () => {
const differentItem = {
...mockReceiptItem,
quantity: 10,
};
fixture.componentRef.setInput('item', differentItem);
expect(component.item().quantity).toBe(10);
});
it('should handle different product information', () => {
const differentItem: ReceiptItem = {
...mockReceiptItem,
product: {
...mockReceiptItem.product,
name: 'Different Product',
contributors: 'Different Author',
productGroup: 'MAGAZINE',
},
};
fixture.componentRef.setInput('item', differentItem);
expect(component.item().product.name).toBe('Different Product');
expect(component.item().product.contributors).toBe('Different Author');
expect(component.item().product.productGroup).toBe('MAGAZINE');
});
it('should handle item with different ID', () => {
const differentItem = {
...mockReceiptItem,
id: 999,
};
fixture.componentRef.setInput('item', differentItem);
expect(component.item().id).toBe(999);
});
});
describe('Component reactivity', () => {
it('should update when item input changes', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component.item().quantity).toBe(5);
expect(component.item().product.name).toBe('Test Product');
// Change the item
const newItem = {
...mockReceiptItem,
id: 2,
quantity: 3,
product: {
...mockReceiptItem.product,
name: 'Updated Product',
},
};
fixture.componentRef.setInput('item', newItem);
expect(component.item().id).toBe(2);
expect(component.item().quantity).toBe(3);
expect(component.item().product.name).toBe('Updated Product');
});
});
describe('Removeable input', () => {
it('should default to false when not provided', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component.removeable()).toBe(false);
});
it('should accept true value', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', true);
expect(component.removeable()).toBe(true);
});
it('should accept false value', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', false);
expect(component.removeable()).toBe(false);
});
});
describe('Product Group functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
});
it('should initialize productGroupResource', () => {
expect(component.productGroupResource).toBeDefined();
});
it('should compute productGroupDetail correctly when resource has data', () => {
// Mock the resource value directly
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
mockProductGroups,
);
// The productGroupDetail should find the matching product group
expect(component.productGroupDetail()).toBe('Books');
});
it('should return empty string when resource value is undefined', () => {
// Mock the resource to return undefined
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
undefined,
);
expect(component.productGroupDetail()).toBe('');
});
it('should return empty string when product group not found', () => {
const differentItem: ReceiptItem = {
...mockReceiptItem,
product: {
...mockReceiptItem.product,
productGroup: 'UNKNOWN',
},
};
fixture.componentRef.setInput('item', differentItem);
// Mock the resource value
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
mockProductGroups,
);
expect(component.productGroupDetail()).toBe('');
});
});
describe('Icon button rendering', () => {
it('should render icon button when removeable is true', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', true);
fixture.detectChanges();
const iconButton = fixture.nativeElement.querySelector('ui-icon-button');
expect(iconButton).toBeTruthy();
});
it('should not render icon button when removeable is false', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', false);
fixture.detectChanges();
const iconButton = fixture.nativeElement.querySelector('ui-icon-button');
expect(iconButton).toBeFalsy();
});
it('should render icon button with correct properties', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', true);
fixture.detectChanges();
const iconButton = fixture.debugElement.query(
By.css('ui-icon-button'),
)?.componentInstance;
expect(iconButton).toBeTruthy();
expect(iconButton.name).toBe('isaActionClose');
expect(iconButton.size).toBe('large');
expect(iconButton.color).toBe('secondary');
});
});
describe('Template rendering', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.detectChanges();
});
it('should render product image with correct attributes', () => {
const img = fixture.nativeElement.querySelector('img');
expect(img).toBeTruthy();
expect(img.getAttribute('alt')).toBe('Test Product');
expect(img.classList.contains('w-full')).toBe(true);
expect(img.classList.contains('max-h-[5.125rem]')).toBe(true);
expect(img.classList.contains('object-contain')).toBe(true);
});
it('should render product contributors', () => {
const contributorsElement = fixture.nativeElement.querySelector(
'.isa-text-body-2-bold',
);
expect(contributorsElement).toBeTruthy();
expect(contributorsElement.textContent).toBe('Test Author');
});
it('should render product name', () => {
const nameElement = fixture.nativeElement.querySelector(
'.isa-text-body-2-regular',
);
expect(nameElement).toBeTruthy();
expect(nameElement.textContent).toBe('Test Product');
});
it('should render bullet list items', () => {
const bulletListItems = fixture.nativeElement.querySelectorAll(
'ui-bullet-list-item',
);
expect(bulletListItems.length).toBe(2);
});
});
describe('Component imports', () => {
it('should have ProductImageDirective import', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
// Component should be created successfully with mocked imports
expect(component).toBeTruthy();
});
it('should have ProductRouterLinkDirective import', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
// Component should be created successfully with mocked imports
expect(component).toBeTruthy();
});
it('should have ProductFormatComponent import', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
// Component should be created successfully with mocked imports
expect(component).toBeTruthy();
});
});
describe('E2E Testing Attributes', () => {
it('should consider adding data-what and data-which attributes for E2E testing', () => {
// This test serves as a reminder that E2E testing attributes
// should be added to the template for better testability.
// Currently the template does not have these attributes.
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.detectChanges();
const hostElement = fixture.nativeElement;
// Verify the component renders (basic check)
expect(hostElement).toBeTruthy();
// Note: In a future update, the template should include:
// - data-what="receipt-item" on the host or main container
// - data-which="receipt-item-details"
// - [attr.data-item-id]="item().id" for dynamic identification
// This would improve E2E test reliability and maintainability
});
});
describe('New inputs - receiptId and returnId', () => {
it('should accept receiptId input', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('receiptId', 123);
expect(component.receiptId()).toBe(123);
});
it('should accept returnId input', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('returnId', 456);
expect(component.returnId()).toBe(456);
});
it('should handle both receiptId and returnId inputs together', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('receiptId', 123);
fixture.componentRef.setInput('returnId', 456);
expect(component.receiptId()).toBe(123);
expect(component.returnId()).toBe(456);
});
});
describe('Remove functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('receiptId', 123);
fixture.componentRef.setInput('returnId', 456);
});
it('should initialize removing signal as false', () => {
expect(component.removing()).toBe(false);
});
it('should call service and emit removed event on successful remove', async () => {
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt.mockResolvedValue(
undefined,
);
let emittedItem: ReceiptItem | undefined;
component.removed.subscribe((item) => {
emittedItem = item;
});
await component.remove();
expect(
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt,
).toHaveBeenCalledWith({
receiptId: 123,
returnId: 456,
receiptItemId: 1,
});
expect(emittedItem).toEqual(mockReceiptItem);
expect(component.removing()).toBe(false);
});
it('should handle remove error gracefully', async () => {
const mockError = new Error('Remove failed');
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt.mockRejectedValue(
mockError,
);
let emittedItem: ReceiptItem | undefined;
component.removed.subscribe((item) => {
emittedItem = item;
});
await component.remove();
expect(
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt,
).toHaveBeenCalledWith({
receiptId: 123,
returnId: 456,
receiptItemId: 1,
});
expect(emittedItem).toBeUndefined();
expect(component.removing()).toBe(false);
});
it('should not call service if already removing', async () => {
component.removing.set(true);
await component.remove();
expect(
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt,
).not.toHaveBeenCalled();
});
});
});

View File

@@ -10,7 +10,6 @@ import {
import {
ReceiptItem,
RemissionReturnReceiptService,
ReturnItem,
} from '@isa/remission/data-access';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { ProductImageDirective } from '@isa/shared/product-image';

View File

@@ -18,13 +18,13 @@
</div>
<div></div>
<remi-remission-return-receipt-details-card
[receipt]="returnResource.value()"
[loading]="returnResource.isLoading()"
[return]="returnData()"
[loading]="returnLoading()"
></remi-remission-return-receipt-details-card>
@let items = returnResource.value()?.items;
@let items = receiptItems();
@if (returnResource.isLoading()) {
@if (returnLoading()) {
<div class="text-center">
<ui-icon-button
class="animate-spin"
@@ -33,31 +33,26 @@
color="neutral"
></ui-icon-button>
</div>
} @else if (items.length === 0) {
} @else if (items?.length === 0 && !returnData()?.completed) {
<div class="flex items-center justify-center">
<ui-empty-state
[title]="emptyWbsTitle"
[description]="emptyWbsDescription"
appearance="noArticles"
>
<button
<lib-remission-return-receipt-actions
class="mt-[1.5rem]"
uiButton
type="button"
appearance="secondary"
size="large"
[disabled]="store.remissionStarted()"
(click)="continueRemission()"
>
Jetzt befüllen
</button>
[remissionReturn]="returnData()"
[displayDeleteAction]="false"
(reloadData)="returnResource.reload()"
></lib-remission-return-receipt-actions>
</ui-empty-state>
</div>
} @else {
<div class="bg-isa-white rounded-2xl p-6 grid grid-flow-row gap-6">
@for (item of items; track item.id; let last = $last) {
<remi-remission-return-receipt-details-item
[item]="item.data"
[item]="item"
[removeable]="canRemoveItems()"
[receiptId]="receiptId()"
[returnId]="returnId()"
@@ -69,22 +64,11 @@
}
</div>
}
@if (!returnResource.isLoading() && !returnResource.value()?.completed) {
<ui-stateful-button
class="fixed right-6 bottom-6"
(clicked)="completeReturn()"
[(state)]="completeReturnState"
defaultContent="Wanne abschließen"
defaultWidth="13rem"
[errorContent]="completeReturnError()"
errorWidth="32rem"
errorAction="Erneut versuchen"
(action)="completeReturn()"
successContent="Wanne abgeschlossen"
successWidth="20rem"
[pending]="completingReturn()"
size="large"
color="brand"
>
</ui-stateful-button>
@if (!returnLoading() && !returnData()?.completed) {
<lib-remission-return-receipt-complete
[returnId]="returnId()"
[receiptId]="receiptId()"
[itemsLength]="items?.length"
(reloadData)="returnResource.reload()"
></lib-remission-return-receipt-complete>
}

View File

@@ -1,316 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MockComponent, MockProvider } from 'ng-mocks';
import { signal } from '@angular/core';
import { Location } from '@angular/common';
import { RemissionReturnReceiptDetailsComponent } from './remission-return-receipt-details.component';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import {
Receipt,
RemissionReturnReceiptService,
RemissionStore,
} from '@isa/remission/data-access';
// Mock the resource function
vi.mock('./resources/remission-return-receipt.resource', () => ({
createRemissionReturnReceiptResource: vi.fn(() => ({
value: signal(null),
isLoading: signal(false),
error: signal(null),
})),
}));
describe('RemissionReturnReceiptDetailsComponent', () => {
let component: RemissionReturnReceiptDetailsComponent;
let fixture: ComponentFixture<RemissionReturnReceiptDetailsComponent>;
const mockReceipt: Receipt = {
id: 123,
receiptNumber: 'RR-2024-001234-ABC',
items: [],
completed: true,
created: new Date('2024-01-15T10:30:00Z'),
} as Receipt;
const mockRemissionReturnReceiptService = {
completeReturnReceiptAndReturn: vi.fn(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsComponent],
providers: [
MockProvider(Location, { back: vi.fn() }),
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionReturnReceiptService,
},
MockProvider(RemissionStore, {
returnId: signal(123),
receiptId: signal(456),
finishRemission: vi.fn(),
}),
],
})
.overrideComponent(RemissionReturnReceiptDetailsComponent, {
remove: {
imports: [
RemissionReturnReceiptDetailsCardComponent,
RemissionReturnReceiptDetailsItemComponent,
],
},
add: {
imports: [
MockComponent(RemissionReturnReceiptDetailsCardComponent),
MockComponent(RemissionReturnReceiptDetailsItemComponent),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(RemissionReturnReceiptDetailsComponent);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component).toBeTruthy();
});
it('should have required inputs', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component.returnId()).toBe(123);
expect(component.receiptId()).toBe(456);
});
it('should coerce string inputs to numbers', () => {
fixture.componentRef.setInput('returnId', '123');
fixture.componentRef.setInput('receiptId', '456');
expect(component.returnId()).toBe(123);
expect(component.receiptId()).toBe(456);
});
});
describe('Dependencies', () => {
it('should inject Location service', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component.location).toBeDefined();
});
it('should create return resource', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component.returnResource).toBeDefined();
});
});
describe('receiptNumber computed signal', () => {
it('should return empty string when no receipt data', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock empty resource
(component.returnResource as any).value = signal(null);
expect(component.receiptNumber()).toBe('');
});
it('should extract receipt number substring correctly', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock resource with receipt data
(component.returnResource as any).value = signal(mockReceipt);
// substring(6, 12) on 'RR-2024-001234-ABC' should return '4-0012'
expect(component.receiptNumber()).toBe('4-0012');
});
it('should handle undefined receipt number', () => {
const receiptWithoutNumber = {
...mockReceipt,
receiptNumber: undefined,
};
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(receiptWithoutNumber);
expect(component.receiptNumber()).toBe('');
});
});
describe('Resource reactivity', () => {
it('should handle resource loading state', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock loading resource
(component.returnResource as any).isLoading = signal(true);
expect(component.returnResource.isLoading()).toBe(true);
});
it('should handle resource with data', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock resource with data
(component.returnResource as any).value = signal(mockReceipt);
(component.returnResource as any).isLoading = signal(false);
expect(component.returnResource.value()).toEqual(mockReceipt);
expect(component.returnResource.isLoading()).toBe(false);
});
});
describe('canRemoveItems computed signal', () => {
it('should return true when receipt is not completed', () => {
const incompleteReceipt = {
...mockReceipt,
completed: false,
};
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(incompleteReceipt);
expect(component.canRemoveItems()).toBe(true);
});
it('should return false when receipt is completed', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(mockReceipt);
expect(component.canRemoveItems()).toBe(false);
});
it('should return false when no receipt data', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(null);
// Fix: canRemoveItems() should be false when no data
expect(component.canRemoveItems()).toBe(false);
});
});
describe('completeReturn functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).reload = vi.fn();
// Reset mocks before each test to avoid call count bleed
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockClear();
if (
component.store.finishRemission &&
'mockClear' in component.store.finishRemission
) {
(component.store.finishRemission as any).mockClear();
}
});
it('should initialize completion state signals', () => {
expect(component.completeReturnState()).toBe('default');
expect(component.completingReturn()).toBe(false);
expect(component.completeReturnError()).toBe(null);
});
it('should complete return successfully', async () => {
const mockCompletedReturn = { ...mockReceipt, completed: true };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockCompletedReturn,
);
await component.completeReturn();
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
expect(component.completeReturnState()).toBe('success');
expect(component.completingReturn()).toBe(false);
expect(component.completeReturnError()).toBe(null);
expect(component.returnResource.reload).toHaveBeenCalled();
});
it('should handle completion error', async () => {
const mockError = new Error('Completion failed');
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockRejectedValue(
mockError,
);
await component.completeReturn();
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
expect(component.completeReturnState()).toBe('error');
expect(component.completingReturn()).toBe(false);
expect(component.completeReturnError()).toBe('Completion failed');
expect(component.returnResource.reload).not.toHaveBeenCalled();
});
it('should handle non-Error objects', async () => {
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockRejectedValue(
'String error',
);
await component.completeReturn();
expect(component.completeReturnState()).toBe('error');
expect(component.completeReturnError()).toBe(
'Wanne konnte nicht abgeschlossen werden',
);
});
it('should call finishRemission on store', async () => {
// Fix: ensure the mock is reset and tracked
if (
component.store.finishRemission &&
'mockClear' in component.store.finishRemission
) {
(component.store.finishRemission as any).mockClear();
}
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
{},
);
await component.completeReturn();
expect(component.store.finishRemission).toHaveBeenCalled();
});
it('should not process if already completing', async () => {
// Fix: ensure no calls are made if already completing
component.completingReturn.set(true);
// Clear any previous calls
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockClear();
await component.completeReturn();
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,43 +4,26 @@ import {
computed,
inject,
input,
signal,
} from '@angular/core';
import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion';
import {
ButtonComponent,
IconButtonComponent,
StatefulButtonComponent,
StatefulButtonState,
} from '@isa/ui/buttons';
import { ButtonComponent, IconButtonComponent } from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft, isaLoading } from '@isa/icons';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import { Location } from '@angular/common';
import { createRemissionReturnReceiptResource } from './resources/remission-return-receipt.resource';
import { createReturnResource } from './resources/return.resource';
import {
RemissionReturnReceiptService,
RemissionStore,
getReceiptItemsFromReturn,
getReceiptNumberFromReturn,
} from '@isa/remission/data-access';
import { logger } from '@isa/core/logging';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { EMPTY_WBS_DESCRIPTION, EMPTY_WBS_TITLE } from './constants';
import {
RemissionReturnReceiptActionsComponent,
RemissionReturnReceiptCompleteComponent,
} from '@isa/remission/shared/return-receipt-actions';
/**
* Component for displaying detailed information about a remission return receipt.
* Shows receipt header information and individual receipt items.
*
* @component
* @selector remi-remission-return-receipt-details
* @standalone
*
* @example
* <remi-remission-return-receipt-details
* [returnId]="123"
* [receiptId]="456">
* </remi-remission-return-receipt-details>
*/
@Component({
selector: 'remi-remission-return-receipt-details',
templateUrl: './remission-return-receipt-details.component.html',
@@ -53,23 +36,18 @@ import { EMPTY_WBS_DESCRIPTION, EMPTY_WBS_TITLE } from './constants';
NgIcon,
RemissionReturnReceiptDetailsCardComponent,
RemissionReturnReceiptDetailsItemComponent,
StatefulButtonComponent,
EmptyStateComponent,
RemissionReturnReceiptActionsComponent,
RemissionReturnReceiptCompleteComponent,
],
providers: [provideIcons({ isaActionChevronLeft, isaLoading })],
})
export class RemissionReturnReceiptDetailsComponent {
#logger = logger(() => ({
component: 'RemissionReturnReceiptDetailsComponent',
}));
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/** Title for the empty state when no remission return receipt is available */
emptyWbsTitle = EMPTY_WBS_TITLE;
emptyWbsDescription = EMPTY_WBS_DESCRIPTION;
/** Instance of the RemissionStore for managing remission state */
store = inject(RemissionStore);
/** Description for the empty state when no remission return receipt is available */
emptyWbsDescription = EMPTY_WBS_DESCRIPTION;
/** Angular Location service for navigation */
location = inject(Location);
@@ -95,66 +73,36 @@ export class RemissionReturnReceiptDetailsComponent {
});
/**
* Resource that fetches the return receipt data based on the provided IDs.
* Automatically updates when input IDs change.
* Computed signal that retrieves the current remission return receipt.
* This is used to display detailed information about the return receipt.
* @returns {Return} The remission return receipt data
*/
returnResource = createRemissionReturnReceiptResource(() => ({
returnResource = createReturnResource(() => ({
returnId: this.returnId(),
receiptId: this.receiptId(),
eagerLoading: 3,
}));
returnLoading = computed(() => this.returnResource.isLoading());
returnData = computed(() => this.returnResource.value());
/**
* Computed signal that extracts the receipt number from the resource.
* Returns a substring of the receipt number (characters 6-12) for display.
* @returns {string} The formatted receipt number or empty string if not available
* Computed signal that retrieves the receipt number from the return data.
* Uses the helper function to get the receipt number.
* @returns {string} The receipt number from the return
*/
receiptNumber = computed(() => {
const ret = this.returnResource.value();
if (!ret) {
return '';
}
const returnData = this.returnData();
return getReceiptNumberFromReturn(returnData!);
});
return ret.receiptNumber?.substring(6, 12) || '';
receiptItems = computed(() => {
const returnData = this.returnData();
return getReceiptItemsFromReturn(returnData!);
});
canRemoveItems = computed(() => {
const ret = this.returnResource.value();
return !!ret && !ret.completed;
const returnData = this.returnData();
return !!returnData && !returnData.completed;
});
completeReturnState = signal<StatefulButtonState>('default');
completingReturn = signal(false);
completeReturnError = signal<string | null>(null);
async continueRemission() {
this.store.startRemission({
returnId: this.returnId(),
receiptId: this.receiptId(),
});
}
async completeReturn() {
if (this.completingReturn()) {
return;
}
this.completingReturn.set(true);
try {
await this.#remissionReturnReceiptService.completeReturnReceiptAndReturn({
returnId: this.returnId(),
receiptId: this.receiptId(),
});
this.store.finishRemission();
this.completeReturnState.set('success');
this.returnResource.reload();
} catch (error) {
this.#logger.error('Failed to complete return', error);
this.completeReturnError.set(
error instanceof Error
? error.message
: 'Wanne konnte nicht abgeschlossen werden',
);
this.completeReturnState.set('error');
}
this.completingReturn.set(false);
}
}

View File

@@ -1,3 +1,3 @@
export * from './product-group.resource';
export * from './remission-return-receipt.resource';
export * from './return.resource';
export * from './supplier.resource';

View File

@@ -1,184 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { runInInjectionContext, Injector } from '@angular/core';
import { MockProvider } from 'ng-mocks';
import { createRemissionReturnReceiptResource } from './remission-return-receipt.resource';
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import { Receipt } from '@isa/remission/data-access';
describe('createRemissionReturnReceiptResource', () => {
let mockService: any;
let mockReceipt: Receipt;
beforeEach(() => {
mockReceipt = {
id: 123,
receiptNumber: 'RR-2024-001234-ABC',
completed: true,
created: new Date('2024-01-15T10:30:00Z'),
supplier: {
id: 456,
name: 'Test Supplier',
},
items: [
{
id: 1,
data: {
id: 1,
quantity: 5,
product: { id: 1, name: 'Product 1' },
},
},
],
packages: [
{
id: 1,
data: {
id: 1,
packageNumber: 'PKG-001',
},
},
],
} as Receipt;
mockService = {
fetchRemissionReturnReceipt: vi.fn().mockResolvedValue(mockReceipt),
};
TestBed.configureTestingModule({
providers: [
MockProvider(RemissionReturnReceiptService, mockService),
],
});
});
describe('Resource Creation', () => {
it('should create resource successfully', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
expect(resource.isLoading).toBeDefined();
expect(resource.error).toBeDefined();
});
it('should inject RemissionReturnReceiptService', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource).toBeDefined();
expect(mockService).toBeDefined();
});
});
describe('Resource Parameters', () => {
it('should handle numeric parameters', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource).toBeDefined();
});
it('should handle string parameters', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: '123',
returnId: '456',
}))
);
expect(resource).toBeDefined();
});
it('should handle mixed parameter types', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: '456',
}))
);
expect(resource).toBeDefined();
});
});
describe('Resource State Management', () => {
it('should provide loading state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource.isLoading).toBeDefined();
expect(typeof resource.isLoading).toBe('function');
});
it('should provide error state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource.error).toBeDefined();
expect(typeof resource.error).toBe('function');
});
it('should provide value state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource.value).toBeDefined();
expect(typeof resource.value).toBe('function');
});
});
describe('Resource Function', () => {
it('should create resource function correctly', () => {
const createResourceFn = () => createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}));
const resource = runInInjectionContext(TestBed.inject(Injector), createResourceFn);
expect(resource).toBeDefined();
expect(typeof resource.value).toBe('function');
expect(typeof resource.isLoading).toBe('function');
expect(typeof resource.error).toBe('function');
});
it('should handle resource initialization', () => {
const params = { receiptId: 123, returnId: 456 };
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => params)
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
expect(resource.isLoading).toBeDefined();
expect(resource.error).toBeDefined();
});
});
});

View File

@@ -1,39 +0,0 @@
import { resource, inject } from '@angular/core';
import {
RemissionReturnReceiptService,
FetchRemissionReturnParams,
} from '@isa/remission/data-access';
/**
* Creates an Angular resource for fetching a specific remission return receipt.
* The resource automatically manages loading state and caching.
*
* @function createRemissionReturnReceiptResource
* @param {Function} params - Function that returns the receipt and return IDs
* @param {string | number} params.receiptId - ID of the receipt to fetch
* @param {string | number} params.returnId - ID of the return containing the receipt
* @returns {Resource} Angular resource that manages the receipt data
*
* @example
* const receiptResource = createRemissionReturnReceiptResource(() => ({
* receiptId: '123',
* returnId: '456'
* }));
*
* // Access the resource value
* const receipt = receiptResource.value();
* const isLoading = receiptResource.isLoading();
*/
export const createRemissionReturnReceiptResource = (
params: () => FetchRemissionReturnParams,
) => {
const remissionReturnReceiptService = inject(RemissionReturnReceiptService);
return resource({
params,
loader: ({ abortSignal, params }) =>
remissionReturnReceiptService.fetchRemissionReturnReceipt(
params,
abortSignal,
),
});
};

View File

@@ -0,0 +1,20 @@
import { resource, inject } from '@angular/core';
import {
FetchReturn,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
/**
* Resource for creating a new remission return.
* It uses the RemissionReturnReceiptService to handle the creation logic.
* @param {Function} params - Function that returns parameters for creating a return
* @return {Resource} Angular resource that manages the return creation data
*/
export const createReturnResource = (params: () => FetchReturn) => {
const remissionReturnReceiptService = inject(RemissionReturnReceiptService);
return resource({
params,
loader: ({ abortSignal, params }) =>
remissionReturnReceiptService.fetchReturn(params, abortSignal),
});
};

View File

@@ -1,5 +1,7 @@
<remi-return-receipt-list-card></remi-return-receipt-list-card>
<div class="flex flex-rows justify-end">
<filter-order-by-toolbar class="w-[44.375rem]"></filter-order-by-toolbar>
<filter-controls-panel></filter-controls-panel>
</div>
<div class="grid grid-flow-rows grid-cols-1 gap-4">
@@ -7,6 +9,7 @@
<a [routerLink]="[remissionReturn[0].id, remissionReturn[1].id]">
<remi-return-receipt-list-item
[remissionReturn]="remissionReturn[0]"
(reloadList)="reloadList()"
></remi-return-receipt-list-item>
</a>
}

View File

@@ -1,3 +1,3 @@
:host {
@apply grid grid-flow-row gap-8 p-6;
@apply w-full grid grid-flow-row gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden;
}

View File

@@ -1,15 +1,9 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Component, signal } from '@angular/core';
import { MockProvider } from 'ng-mocks';
import { RemissionReturnReceiptListComponent } from './remission-return-receipt-list.component';
import {
RemissionReturnReceiptService,
Return,
Receipt,
} from '@isa/remission/data-access';
import { Return, Receipt } from '@isa/remission/data-access';
import { FilterService } from '@isa/shared/filter';
import { of } from 'rxjs';
// Mock the filter providers
vi.mock('@isa/shared/filter', async () => {
@@ -22,6 +16,12 @@ vi.mock('@isa/shared/filter', async () => {
};
});
// Mock the resources
vi.mock('./resources', () => ({
completedRemissionReturnsResource: vi.fn(),
incompletedRemissionReturnsResource: vi.fn(),
}));
// Mock child components
@Component({
selector: 'remi-return-receipt-list-item',
@@ -31,21 +31,25 @@ vi.mock('@isa/shared/filter', async () => {
class MockReturnReceiptListItemComponent {}
@Component({
selector: 'remi-order-by-toolbar',
template: '<div>Mock Order By Toolbar</div>',
selector: 'remi-remission-return-receipt-list-card',
template: '<div>Mock Return Receipt List Card</div>',
standalone: true,
})
class MockOrderByToolbarComponent {}
class MockRemissionReturnReceiptListCardComponent {}
@Component({
selector: 'isa-filter-controls-panel',
template: '<div>Mock Filter Controls Panel</div>',
standalone: true,
})
class MockFilterControlsPanelComponent {}
describe('RemissionReturnReceiptListComponent', () => {
let component: RemissionReturnReceiptListComponent;
let fixture: ComponentFixture<RemissionReturnReceiptListComponent>;
let mockRemissionReturnReceiptService: {
fetchRemissionReturnReceipts: ReturnType<typeof vi.fn>;
};
let mockFilterService: {
orderBy: ReturnType<typeof signal>;
};
let mockFilterService: any;
let mockCompletedResource: any;
let mockIncompletedResource: any;
const mockCompletedReturn: Return = {
id: 1,
@@ -81,54 +85,58 @@ describe('RemissionReturnReceiptListComponent', () => {
],
} as Return;
const mockReturnWithoutReceiptData: Return = {
id: 3,
completed: '2024-01-17T10:30:00.000Z',
receipts: [
{
id: 103,
data: undefined,
},
],
} as Return;
const mockReturns = [mockCompletedReturn, mockIncompletedReturn];
beforeEach(async () => {
// Arrange: Setup mocks
mockRemissionReturnReceiptService = {
fetchRemissionReturnReceipts: vi.fn().mockReturnValue(of(mockReturns)),
};
// Setup mocks
mockFilterService = {
orderBy: signal([]),
inputs: signal([]),
groups: signal([]),
queryParams: signal({}),
query: signal({ filter: {}, input: {}, orderBy: [] }),
isEmpty: signal(true),
isDefaultFilter: signal(true),
selectedFilterCount: signal(0),
};
mockCompletedResource = {
value: vi.fn().mockReturnValue([mockCompletedReturn]),
reload: vi.fn(),
isLoading: vi.fn().mockReturnValue(false),
};
mockIncompletedResource = {
value: vi.fn().mockReturnValue([mockIncompletedReturn]),
reload: vi.fn(),
isLoading: vi.fn().mockReturnValue(false),
};
// Mock the resource functions
const {
completedRemissionReturnsResource,
incompletedRemissionReturnsResource,
} = await import('./resources');
vi.mocked(completedRemissionReturnsResource).mockReturnValue(
mockCompletedResource,
);
vi.mocked(incompletedRemissionReturnsResource).mockReturnValue(
mockIncompletedResource,
);
await TestBed.configureTestingModule({
imports: [
RemissionReturnReceiptListComponent,
MockReturnReceiptListItemComponent,
MockOrderByToolbarComponent,
],
providers: [
MockProvider(
RemissionReturnReceiptService,
mockRemissionReturnReceiptService
),
{
provide: FilterService,
useValue: mockFilterService,
},
],
imports: [RemissionReturnReceiptListComponent],
providers: [{ provide: FilterService, useValue: mockFilterService }],
})
.overrideComponent(RemissionReturnReceiptListComponent, {
remove: {
imports: [],
imports: [
// Remove original components
],
},
add: {
imports: [
MockReturnReceiptListItemComponent,
MockOrderByToolbarComponent,
MockRemissionReturnReceiptListCardComponent,
MockFilterControlsPanelComponent,
],
},
})
@@ -138,102 +146,40 @@ describe('RemissionReturnReceiptListComponent', () => {
component = fixture.componentInstance;
});
describe('Component Initialization', () => {
it('should create the component', () => {
// Assert
describe('Component Setup', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should inject dependencies correctly', () => {
// Assert - Private fields cannot be directly tested
// Instead, we verify the component initializes correctly with dependencies
expect(component).toBeDefined();
it('should initialize resources', () => {
expect(component.completedRemissionReturnsResource).toBeDefined();
expect(component.incompletedRemissionReturnsResource).toBeDefined();
expect(component.orderDateBy).toBeDefined();
});
it('should render the component', () => {
// Act
fixture.detectChanges();
// Assert
expect(fixture.nativeElement).toBeTruthy();
expect(fixture.componentInstance).toBe(component);
});
});
describe('Resource Loading', () => {
it('should initialize completed and incomplete resources', () => {
// Assert
expect(component.completedRemissionReturnsResource).toBeDefined();
expect(component.incompletedRemissionReturnsResource).toBeDefined();
describe('orderDateBy computed signal', () => {
it('should return undefined when no order is selected', () => {
mockFilterService.orderBy.set([]);
const orderBy = component.orderDateBy();
expect(orderBy).toBeUndefined();
});
it('should fetch remission return receipts on component initialization', () => {
// Arrange
mockRemissionReturnReceiptService.fetchRemissionReturnReceipts.mockClear();
it('should return selected order option', () => {
const selectedOrder = { selected: true, by: 'created', dir: 'desc' };
mockFilterService.orderBy.set([selectedOrder]);
// Act
fixture.detectChanges();
const orderBy = component.orderDateBy();
// Assert
expect(
mockRemissionReturnReceiptService.fetchRemissionReturnReceipts
).toHaveBeenCalled();
});
it('should handle loading state', () => {
// Arrange
mockRemissionReturnReceiptService.fetchRemissionReturnReceipts.mockReturnValue(
new Promise(() => undefined) // Never resolving promise to simulate loading
);
// Act
const newFixture = TestBed.createComponent(
RemissionReturnReceiptListComponent
);
const newComponent = newFixture.componentInstance;
// Assert
expect(newComponent.completedRemissionReturnsResource.isLoading()).toBeDefined();
expect(newComponent.incompletedRemissionReturnsResource.isLoading()).toBeDefined();
});
it('should handle error state when service fails', async () => {
// Arrange
const errorMessage = 'Service failed';
mockRemissionReturnReceiptService.fetchRemissionReturnReceipts.mockReturnValue(
Promise.reject(new Error(errorMessage))
);
// Act
const errorFixture = TestBed.createComponent(
RemissionReturnReceiptListComponent
);
errorFixture.detectChanges();
await errorFixture.whenStable();
// Assert
const errorComponent = errorFixture.componentInstance;
expect(errorComponent.completedRemissionReturnsResource.error).toBeDefined();
expect(orderBy).toBe(selectedOrder);
});
});
describe('returns computed signal', () => {
it('should combine returns with incompleted first', () => {
// Arrange
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
mockCompletedReturn
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([
mockIncompletedReturn
]);
// Act
it('should combine completed and incompleted returns', () => {
const returns = component.returns();
// Assert
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(mockIncompletedReturn);
expect(returns[0][1]).toBe(mockIncompletedReturn.receipts[0].data);
@@ -241,401 +187,22 @@ describe('RemissionReturnReceiptListComponent', () => {
expect(returns[1][1]).toBe(mockCompletedReturn.receipts[0].data);
});
it('should filter out receipts without data', () => {
// Arrange
const returnsWithNullData = [mockCompletedReturn, mockReturnWithoutReceiptData];
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue(
returnsWithNullData
);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
it('should handle empty returns', () => {
mockCompletedResource.value.mockReturnValue([]);
mockIncompletedResource.value.mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(1);
expect(returns[0][0]).toBe(mockCompletedReturn);
expect(returns[0][1]).toBe(mockCompletedReturn.receipts[0].data);
});
it('should handle empty returns array', () => {
// Arrange
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(0);
expect(returns).toEqual([]);
});
it('should handle null value from resource', () => {
// Arrange
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue(
undefined as Return[] | undefined
);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(0);
expect(returns).toEqual([]);
});
it('should handle undefined value from resource', () => {
// Arrange
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue(
undefined as Return[] | undefined
);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(0);
expect(returns).toEqual([]);
});
it('should flatten multiple receipts per return', () => {
// Arrange
const returnWithMultipleReceipts: Return = {
id: 4,
completed: '2024-01-15T10:00:00.000Z',
receipts: [
{
id: 201,
data: {
id: 201,
receiptNumber: 'REC-2024-201',
created: '2024-01-15T09:00:00.000Z',
completed: '2024-01-15T10:00:00.000Z',
items: [],
} as Receipt,
},
{
id: 202,
data: {
id: 202,
receiptNumber: 'REC-2024-202',
created: '2024-01-15T10:00:00.000Z',
completed: '2024-01-15T11:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
returnWithMultipleReceipts,
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(returnWithMultipleReceipts);
expect(returns[0][1]).toBe(returnWithMultipleReceipts.receipts[0].data);
expect(returns[1][0]).toBe(returnWithMultipleReceipts);
expect(returns[1][1]).toBe(returnWithMultipleReceipts.receipts[1].data);
});
});
describe('orderDateBy computed signal', () => {
it('should return undefined when no order is selected', () => {
// Arrange
mockFilterService.orderBy = signal([]);
describe('reloadList method', () => {
it('should reload both resources', () => {
component.reloadList();
// Act
const orderBy = component.orderDateBy();
// Assert
expect(orderBy).toBeUndefined();
});
it('should return selected order option', () => {
// Arrange
const selectedOrder = { selected: true, by: 'created', dir: 'desc' };
const notSelectedOrder = { selected: false, by: 'completed', dir: 'asc' };
// Update the existing mockFilterService signal
mockFilterService.orderBy.set([notSelectedOrder, selectedOrder]);
const newFixture = TestBed.createComponent(
RemissionReturnReceiptListComponent
);
const newComponent = newFixture.componentInstance;
// Act
const orderBy = newComponent.orderDateBy();
// Assert
expect(orderBy).toBe(selectedOrder);
});
});
describe('Sorting functionality', () => {
it('should sort returns by created date in descending order', () => {
// Arrange
const orderOption = { selected: true, by: 'created', dir: 'desc' };
mockFilterService.orderBy.set([orderOption]);
const olderReturn: Return = {
id: 10,
completed: '2024-01-15T10:00:00.000Z',
created: '2024-01-10T09:00:00.000Z',
receipts: [
{
id: 301,
data: {
id: 301,
receiptNumber: 'REC-2024-301',
created: '2024-01-10T09:00:00.000Z',
completed: '2024-01-10T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
const newerReturn: Return = {
id: 11,
completed: '2024-01-15T10:00:00.000Z',
created: '2024-01-20T09:00:00.000Z',
receipts: [
{
id: 302,
data: {
id: 302,
receiptNumber: 'REC-2024-302',
created: '2024-01-20T09:00:00.000Z',
completed: '2024-01-20T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
olderReturn,
newerReturn,
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(newerReturn); // Newer date should come first in desc order
expect(returns[1][0]).toBe(olderReturn);
});
it('should sort returns by created date in ascending order', () => {
// Arrange
const orderOption = { selected: true, by: 'created', dir: 'asc' };
mockFilterService.orderBy.set([orderOption]);
const sortedFixture = TestBed.createComponent(
RemissionReturnReceiptListComponent
);
const sortedComponent = sortedFixture.componentInstance;
const olderReturn: Return = {
id: 10,
completed: '2024-01-15T10:00:00.000Z',
created: '2024-01-10T09:00:00.000Z',
receipts: [
{
id: 301,
data: {
id: 301,
receiptNumber: 'REC-2024-301',
created: '2024-01-10T09:00:00.000Z',
completed: '2024-01-10T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
const newerReturn: Return = {
id: 11,
completed: '2024-01-15T10:00:00.000Z',
created: '2024-01-20T09:00:00.000Z',
receipts: [
{
id: 302,
data: {
id: 302,
receiptNumber: 'REC-2024-302',
created: '2024-01-20T09:00:00.000Z',
completed: '2024-01-20T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
vi.spyOn(sortedComponent.completedRemissionReturnsResource, 'value').mockReturnValue([
newerReturn,
olderReturn,
]);
vi.spyOn(sortedComponent.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = sortedComponent.returns();
// Assert
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(olderReturn); // Older date should come first in asc order
expect(returns[1][0]).toBe(newerReturn);
});
it('should handle sorting with undefined dates', () => {
// Arrange
const orderOption = { selected: true, by: 'created', dir: 'desc' };
mockFilterService.orderBy = signal([orderOption]);
const returnWithDate: Return = {
id: 10,
completed: '2024-01-15T10:00:00.000Z',
created: '2024-01-10T09:00:00.000Z',
receipts: [
{
id: 301,
data: {
id: 301,
receiptNumber: 'REC-2024-301',
created: '2024-01-10T09:00:00.000Z',
completed: '2024-01-10T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
const returnWithoutDate: Return = {
id: 11,
completed: '2024-01-15T10:00:00.000Z',
created: undefined,
receipts: [
{
id: 302,
data: {
id: 302,
receiptNumber: 'REC-2024-302',
created: '2024-01-20T09:00:00.000Z',
completed: '2024-01-20T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
returnWithDate,
returnWithoutDate,
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(returnWithDate); // Item with date should come first
expect(returns[1][0]).toBe(returnWithoutDate); // Undefined date goes to end
});
});
describe('Component Destruction', () => {
it('should handle component destruction gracefully', () => {
// Act
fixture.destroy();
// Assert
expect(component).toBeDefined();
});
});
describe('Edge Cases', () => {
it('should handle returns with empty receipts array', () => {
// Arrange
const returnWithEmptyReceipts: Return = {
id: 100,
completed: '2024-01-15T10:00:00.000Z',
receipts: [],
} as Return;
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
returnWithEmptyReceipts,
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(0);
});
it('should handle mixed returns with and without receipt data', () => {
// Arrange
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
mockCompletedReturn,
mockReturnWithoutReceiptData
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([
mockIncompletedReturn
]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(2); // Only returns with receipt data
expect(returns[0][0]).toBe(mockIncompletedReturn); // Incompleted first
expect(returns[1][0]).toBe(mockCompletedReturn);
});
it('should handle very large number of receipts per return', () => {
// Arrange
const returnWithManyReceipts: Return = {
id: 200,
completed: '2024-01-15T10:00:00.000Z',
receipts: Array.from({ length: 100 }, (_, i) => ({
id: 1000 + i,
data: {
id: 1000 + i,
receiptNumber: `REC-2024-${1000 + i}`,
created: '2024-01-15T09:00:00.000Z',
completed: '2024-01-15T10:00:00.000Z',
items: [],
} as Receipt,
})),
} as Return;
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
returnWithManyReceipts,
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(100);
returns.forEach(([returnData, receipt]) => {
expect(returnData).toBe(returnWithManyReceipts);
expect(receipt).toBeDefined();
});
expect(mockCompletedResource.reload).toHaveBeenCalled();
expect(mockIncompletedResource.reload).toHaveBeenCalled();
});
});
});

View File

@@ -3,29 +3,28 @@ import {
Component,
computed,
inject,
resource,
} from '@angular/core';
import { ReturnReceiptListItemComponent } from './return-receipt-list-item/return-receipt-list-item.component';
import {
Receipt,
RemissionReturnReceiptService,
Return,
} from '@isa/remission/data-access';
import { Receipt, Return } from '@isa/remission/data-access';
import {
provideFilter,
withQueryParamsSync,
withQuerySettings,
OrderByToolbarComponent,
FilterService,
FilterControlsPanelComponent,
} from '@isa/shared/filter';
import { RouterLink } from '@angular/router';
import { compareAsc, compareDesc, subDays } from 'date-fns';
import { RETURN_RECEIPT_QUERY_SETTINGS } from './remission-return-receipt-list.query-settings';
import { RemissionReturnReceiptListCardComponent } from './return-receipt-list-card/return-receipt-list-card.component';
import {
completedRemissionReturnsResource,
incompletedRemissionReturnsResource,
} from './resources';
/**
* Component that displays a list of remission return receipts.
* Fetches both completed and incomplete receipts and combines them for display.
* Supports filtering and sorting through query parameters.
* Component for displaying a list of remission return receipts.
* It shows both completed and incomplete receipts, with options to filter and sort.
*
* @component
* @selector remi-remission-return-receipt-list
@@ -41,8 +40,9 @@ import { RETURN_RECEIPT_QUERY_SETTINGS } from './remission-return-receipt-list.q
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
RemissionReturnReceiptListCardComponent,
FilterControlsPanelComponent,
ReturnReceiptListItemComponent,
OrderByToolbarComponent,
RouterLink,
],
providers: [
@@ -53,46 +53,41 @@ import { RETURN_RECEIPT_QUERY_SETTINGS } from './remission-return-receipt-list.q
],
})
export class RemissionReturnReceiptListComponent {
/** Private instance of the remission return receipt service */
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/** Filter service for managing filter state and operations */
#filter = inject(FilterService);
/**
* Computed signal that retrieves the currently selected order date for sorting.
* This is used to determine how the return receipts should be ordered.
* @returns {string | undefined} The selected order date
*/
orderDateBy = computed(() => this.#filter.orderBy().find((o) => o.selected));
/**
* Resource that fetches completed remission return receipts.
* Automatically loads when the component is initialized.
*/
completedRemissionReturnsResource = resource({
loader: ({ abortSignal }) =>
this.#remissionReturnReceiptService.fetchRemissionReturnReceipts(
{ returncompleted: true, start: subDays(new Date(), 7) },
abortSignal,
),
/** Resource for fetching completed remission return receipts */
completedRemissionReturnsResource = completedRemissionReturnsResource();
/** Resource for fetching incomplete remission return receipts */
incompletedRemissionReturnsResource = incompletedRemissionReturnsResource();
/** Computed signal that retrieves the value of the completed remission returns resource */
completedRemissionReturnsResourceValue = computed(() => {
return this.completedRemissionReturnsResource.value() || [];
});
/** Computed signal that retrieves the value of the incomplete remission returns resource */
incompletedRemissionReturnsResourceValue = computed(() => {
return this.incompletedRemissionReturnsResource.value() || [];
});
/**
* Resource that fetches incomplete remission return receipts.
* Automatically loads when the component is initialized.
*/
incompletedRemissionReturnsResource = resource({
loader: ({ abortSignal }) =>
this.#remissionReturnReceiptService.fetchRemissionReturnReceipts(
{ returncompleted: false },
abortSignal,
),
});
/**
* Computed signal that combines completed and incomplete returns.
* Maps each return with its receipts into tuples for display.
* When date ordering is selected, sorts by completion date with incomplete items first.
* @returns {Array<[Return, Receipt]>} Array of tuples containing return and receipt pairs
* Computed signal that combines completed and incomplete remission returns,
* filtering out any receipts that do not have associated data.
* It also orders the returns based on the selected order date.
* @returns {Array<[Return, Receipt]>} Array of tuples containing Return and Receipt objects
*/
returns = computed(() => {
let completed = this.completedRemissionReturnsResource.value() || [];
let incompleted = this.incompletedRemissionReturnsResource.value() || [];
let completed = this.completedRemissionReturnsResourceValue();
let incompleted = this.incompletedRemissionReturnsResourceValue();
const orderBy = this.orderDateBy();
if (orderBy) {
@@ -113,8 +108,27 @@ export class RemissionReturnReceiptListComponent {
.map((rec) => [ret, rec.data] as [Return, Receipt]),
);
});
/**
* Reloads the completed and incomplete remission returns resources.
* This is typically called when the user performs an action that requires
* refreshing the list of returns, such as after adding or deleting a return.
*/
reloadList() {
this.completedRemissionReturnsResource.reload();
this.incompletedRemissionReturnsResource.reload();
}
}
/**
* Helper function to order an array of objects by a specific key.
* Uses a custom comparison function to sort the items.
*
* @param items - Array of items to be sorted
* @param by - Key to sort by
* @param compareFn - Comparison function for sorting
* @returns Sorted array of items
*/
function orderByKey<T, K extends keyof T>(
items: T[],
by: K,

View File

@@ -0,0 +1,56 @@
import { inject, resource } from '@angular/core';
import {
FetchRemissionReturnReceipts,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
import { subDays } from 'date-fns';
/**
* Resource for fetching completed remission return receipts.
* It retrieves receipts that are marked as completed within a specified date range.
* @param {Function} params - Function that returns parameters for fetching receipts
* @param {Object} params.returncompleted - Boolean indicating if the return is completed
* @param {Date} params.start - Start date for filtering receipts
* @return {Resource} Angular resource that manages the completed receipts data
*/
export const completedRemissionReturnsResource = (
params: () => FetchRemissionReturnReceipts = () => ({
returncompleted: true,
start: subDays(new Date(), 7),
}),
) => {
const remissionReturnReceiptService = inject(RemissionReturnReceiptService);
return resource({
params,
loader: ({ abortSignal, params }) => {
return remissionReturnReceiptService.fetchRemissionReturnReceipts(
params,
abortSignal,
);
},
});
};
/**
* Resource for fetching incomplete remission return receipts.
* It retrieves receipts that are not marked as completed.
* @param {Function} params - Function that returns parameters for fetching receipts
* @param {Object} params.returncompleted - Boolean indicating if the return is completed
* @return {Resource} Angular resource that manages the incomplete receipts data
*/
export const incompletedRemissionReturnsResource = (
params: () => FetchRemissionReturnReceipts = () => ({
returncompleted: false,
}),
) => {
const remissionReturnReceiptService = inject(RemissionReturnReceiptService);
return resource({
params,
loader: ({ abortSignal, params }) => {
return remissionReturnReceiptService.fetchRemissionReturnReceipts(
params,
abortSignal,
);
},
});
};

View File

@@ -0,0 +1 @@
export * from './fetch-return-receipt-list.recource';

View File

@@ -0,0 +1,9 @@
<div class="remi-return-receipt-list-card__title-container">
<h2 class="isa-text-subtitle-1-regular">Warenbegleitscheine</h2>
<p class="isa-text-body-1-regular">
Offene Warenbegleitscheine können nur gelöscht werden, wenn sie keine
Artikel enthalten. Entfernen Sie diese, bevor Sie den Warenbegleitschein
löschen.
</p>
</div>

View File

@@ -0,0 +1,7 @@
:host {
@apply w-full flex flex-row gap-6 rounded-2xl bg-isa-neutral-400 p-6 justify-between;
}
.remi-return-receipt-list-card__title-container {
@apply flex flex-col gap-4 text-isa-neutral-900;
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'remi-return-receipt-list-card',
templateUrl: './return-receipt-list-card.component.html',
styleUrl: './return-receipt-list-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RemissionReturnReceiptListCardComponent {}

View File

@@ -1,14 +1,30 @@
<div class="flex flex-col">
<div>Warenbegleitschein</div>
<div class="isa-text-body-1-bold">#{{ receiptNumber() }}</div>
</div>
<div
class="flex flex-col gap-6 p-6 rounded-2xl text-isa-neutral-900 isa-text-body-1-regular"
[class.bg-isa-white]="status() === ReceiptCompleteStatus.Offen"
[class.bg-isa-neutral-400]="status() === ReceiptCompleteStatus.Abgeschlossen"
>
<div class="flex flex-row justify-start gap-6">
<div class="flex flex-col">
<div>Warenbegleitschein</div>
<div class="isa-text-body-1-bold">#{{ receiptNumber() }}</div>
</div>
<div class="flex flex-col">
<div>Anzahl Positionen</div>
<div class="isa-text-body-1-bold">{{ itemQuantity() }}</div>
</div>
<div class="flex-grow"></div>
<div class="flex flex-col">
<div>Status</div>
<div class="isa-text-body-1-bold">{{ status() }}</div>
<div class="flex flex-col">
<div>Anzahl Positionen</div>
<div class="isa-text-body-1-bold">{{ itemQuantity() }}</div>
</div>
<div class="flex-grow"></div>
<div class="flex flex-col w-32">
<div>Status</div>
<div class="isa-text-body-1-bold">{{ status() }}</div>
</div>
</div>
@if (status() === ReceiptCompleteStatus.Offen) {
<lib-remission-return-receipt-actions
[remissionReturn]="remissionReturn()"
(reloadData)="reloadList.emit()"
>
</lib-remission-return-receipt-actions>
}
</div>

View File

@@ -1,11 +0,0 @@
:host {
@apply flex flex-row justify-start gap-6 p-6 rounded-2xl text-isa-neutral-900 isa-text-body-1-regular;
&.remi-return-receipt-list-item--offen {
@apply bg-isa-white;
}
&.remi-return-receipt-list-item--abgeschlossen {
@apply bg-isa-neutral-400;
}
}

View File

@@ -1,7 +1,27 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ReturnReceiptListItemComponent } from './return-receipt-list-item.component';
import { Return } from '@isa/remission/data-access';
import {
Return,
ReceiptCompleteStatus,
getReceiptNumberFromReturn,
getReceiptItemQuantityFromReturn,
getReceiptStatusFromReturn,
} from '@isa/remission/data-access';
import { MockComponent } from 'ng-mocks';
import { RemissionReturnReceiptActionsComponent } from '@isa/remission/shared/return-receipt-actions';
// Mock the helper functions
vi.mock('@isa/remission/data-access', async () => {
const actual = await vi.importActual('@isa/remission/data-access');
return {
...actual,
getReceiptNumberFromReturn: vi.fn(),
getReceiptItemQuantityFromReturn: vi.fn(),
getReceiptStatusFromReturn: vi.fn(),
};
});
describe('ReturnReceiptListItemComponent', () => {
let component: ReturnReceiptListItemComponent;
@@ -10,13 +30,23 @@ describe('ReturnReceiptListItemComponent', () => {
const createMockReturn = (overrides: Partial<Return> = {}): Return =>
({
id: 1,
receipts: [],
receipts: [
{ id: 101, items: [] },
{ id: 102, items: [] },
],
...overrides,
}) as Return;
beforeEach(async () => {
// Reset all mocks before each test
vi.clearAllMocks();
await TestBed.configureTestingModule({
imports: [ReturnReceiptListItemComponent],
imports: [
HttpClientTestingModule,
ReturnReceiptListItemComponent,
MockComponent(RemissionReturnReceiptActionsComponent),
],
}).compileComponents();
fixture = TestBed.createComponent(ReturnReceiptListItemComponent);
@@ -25,595 +55,311 @@ describe('ReturnReceiptListItemComponent', () => {
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('remissionReturn', createMockReturn());
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
expect(component).toBeTruthy();
});
it('should have remissionReturn as required input', () => {
fixture.componentRef.setInput('remissionReturn', createMockReturn());
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.remissionReturn()).toBeDefined();
expect(component.remissionReturn()).toEqual(mockReturn);
});
it('should have correct host class applied', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const hostElement = fixture.debugElement.nativeElement;
expect(
hostElement.classList.contains('remi-return-receipt-list-item'),
).toBe(true);
});
});
describe('receiptNumber computed signal', () => {
it('should return "Keine Belege vorhanden" when no receipts', () => {
const mockReturn = createMockReturn({ receipts: [] });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
describe('Computed signals', () => {
describe('receiptNumber', () => {
it('should return receipt number from helper function', () => {
const mockReturn = createMockReturn();
const expectedReceiptNumber = '24-001';
expect(component.receiptNumber()).toBe('Keine Belege vorhanden');
vi.mocked(getReceiptNumberFromReturn).mockReturnValue(
expectedReceiptNumber,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe(expectedReceiptNumber);
expect(getReceiptNumberFromReturn).toHaveBeenCalledWith(mockReturn);
});
});
it('should return single receipt number substring', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: [],
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
describe('itemQuantity', () => {
it('should return item quantity from helper function', () => {
const mockReturn = createMockReturn();
const expectedQuantity = 5;
expect(component.receiptNumber()).toBe('24-001');
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(
expectedQuantity,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(expectedQuantity);
expect(getReceiptItemQuantityFromReturn).toHaveBeenCalledWith(
mockReturn,
);
});
});
it('should return multiple receipt numbers joined with comma', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: [],
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: [],
},
},
{
id: 3,
data: {
id: 3,
receiptNumber: 'REC-2024-003-GHI',
items: [],
},
},
],
describe('status', () => {
it('should return status from helper function using linkedSignal', () => {
const mockReturn = createMockReturn();
const expectedStatus = ReceiptCompleteStatus.Abgeschlossen;
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(expectedStatus);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.status()).toBe(expectedStatus);
expect(getReceiptStatusFromReturn).toHaveBeenCalledWith(mockReturn);
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-001, 24-002, 24-003');
});
it('should update when input changes', () => {
const mockReturn1 = createMockReturn({ id: 1 });
const mockReturn2 = createMockReturn({ id: 2 });
it('should handle receipts with null data', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: [],
},
},
],
// Setup first status
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn1);
fixture.detectChanges();
expect(component.status()).toBe(ReceiptCompleteStatus.Offen);
// Change status for second return
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Abgeschlossen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn2);
fixture.detectChanges();
expect(component.status()).toBe(ReceiptCompleteStatus.Abgeschlossen);
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-002');
});
it('should handle receipts with undefined receiptNumber', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: undefined as any,
items: [],
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: [],
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-002');
});
it('should handle short receipt numbers', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'SHORT',
items: [],
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('');
});
});
describe('itemQuantity computed signal', () => {
it('should return 0 when no receipts', () => {
const mockReturn = createMockReturn({ receipts: [] });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(0);
describe('Template rendering', () => {
beforeEach(() => {
// Setup default mock values for template rendering tests
vi.mocked(getReceiptNumberFromReturn).mockReturnValue('24-001');
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(3);
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
});
it('should return sum of all items across receipts', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: new Array(5),
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: new Array(3),
},
},
{
id: 3,
data: {
id: 3,
receiptNumber: 'REC-2024-003-GHI',
items: new Array(7),
},
},
],
});
it('should display receipt information correctly', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(15);
const compiled = fixture.nativeElement;
// Check receipt number display
expect(compiled.textContent).toContain('#24-001');
// Check item quantity display
expect(compiled.textContent).toContain('3');
// Check status display
expect(compiled.textContent).toContain('Offen');
});
it('should handle receipts with null data', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
items: new Array(3),
},
},
],
});
it('should apply correct background class for "Offen" status', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(3);
const containerDiv = fixture.nativeElement.querySelector(
'.flex.flex-col.gap-6',
);
expect(containerDiv.classList.contains('bg-isa-white')).toBe(true);
expect(containerDiv.classList.contains('bg-isa-neutral-400')).toBe(false);
});
it('should handle receipts with undefined items', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: undefined as any,
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: new Array(5),
},
},
],
});
it('should apply correct background class for "Abgeschlossen" status', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Abgeschlossen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(5);
const containerDiv = fixture.nativeElement.querySelector(
'.flex.flex-col.gap-6',
);
expect(containerDiv.classList.contains('bg-isa-neutral-400')).toBe(true);
expect(containerDiv.classList.contains('bg-isa-white')).toBe(false);
});
it('should handle receipts with empty items array', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: [],
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: new Array(2),
},
},
],
});
it('should show action component for "Offen" status', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(2);
const actionsComponent = fixture.nativeElement.querySelector(
'lib-remission-return-receipt-actions',
);
expect(actionsComponent).toBeTruthy();
});
it('should hide action component for "Abgeschlossen" status', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Abgeschlossen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const actionsComponent = fixture.nativeElement.querySelector(
'lib-remission-return-receipt-actions',
);
expect(actionsComponent).toBeFalsy();
});
it('should pass correct inputs to action component', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const actionsComponent = fixture.nativeElement.querySelector(
'lib-remission-return-receipt-actions',
);
expect(actionsComponent).toBeTruthy();
// Since we're using MockComponent, we can verify the component is present
// and that it should receive the remissionReturn input based on the template
// The actual input binding testing would require a more complex setup
expect(actionsComponent.tagName.toLowerCase()).toBe(
'lib-remission-return-receipt-actions',
);
});
});
describe('completed computed signal', () => {
it('should return "Offen" when no receipts are completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
{
id: 2,
data: {
id: 2,
completed: false,
},
},
],
});
describe('Output events', () => {
it('should emit reloadList when action component emits reloadData', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Offen');
});
const reloadListSpy = vi.fn();
component.reloadList.subscribe(reloadListSpy);
it('should return "Abgeschlossen" when at least one receipt is completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
{
id: 2,
data: {
id: 2,
completed: true,
},
},
{
id: 3,
data: {
id: 3,
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
// Simulate the action component emitting reloadData
const actionsComponent = fixture.nativeElement.querySelector(
'lib-remission-return-receipt-actions',
);
// Trigger the reloadData event from the actions component
actionsComponent.dispatchEvent(new CustomEvent('reloadData'));
fixture.detectChanges();
expect(component.completed()).toBe('Abgeschlossen');
});
it('should return "Abgeschlossen" when all receipts are completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: true,
},
},
{
id: 2,
data: {
id: 2,
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Abgeschlossen');
});
it('should return "Offen" when no receipts exist', () => {
const mockReturn = createMockReturn({ receipts: [] });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Offen');
});
it('should handle receipts with null data', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Offen');
});
it('should handle receipts with undefined completed status', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: [],
completed: undefined as any,
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: [],
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Abgeschlossen');
expect(reloadListSpy).toHaveBeenCalled();
});
});
describe('Component reactivity', () => {
it('should update computed signals when input changes', () => {
const initialReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: new Array(3),
completed: false,
},
},
],
});
const initialReturn = createMockReturn({ id: 1 });
const updatedReturn = createMockReturn({ id: 2 });
// Mock return values for initial state
vi.mocked(getReceiptNumberFromReturn).mockReturnValue('24-001');
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(3);
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', initialReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-001');
expect(component.itemQuantity()).toBe(3);
expect(component.completed()).toBe('Offen');
expect(component.status()).toBe(ReceiptCompleteStatus.Offen);
// Mock return values for updated state
vi.mocked(getReceiptNumberFromReturn).mockReturnValue('24-002, 24-003');
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(7);
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Abgeschlossen,
);
const updatedReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-002-DEF',
items: new Array(5),
completed: true,
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-003-GHI',
items: new Array(2),
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', updatedReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-002, 24-003');
expect(component.itemQuantity()).toBe(7);
expect(component.completed()).toBe('Abgeschlossen');
expect(component.status()).toBe(ReceiptCompleteStatus.Abgeschlossen);
});
it('should trigger helper functions with correct parameters when input changes', () => {
const mockReturn1 = createMockReturn({ id: 1 });
const mockReturn2 = createMockReturn({ id: 2 });
// First input
fixture.componentRef.setInput('remissionReturn', mockReturn1);
fixture.detectChanges();
expect(getReceiptNumberFromReturn).toHaveBeenCalledWith(mockReturn1);
expect(getReceiptItemQuantityFromReturn).toHaveBeenCalledWith(
mockReturn1,
);
expect(getReceiptStatusFromReturn).toHaveBeenCalledWith(mockReturn1);
// Clear mock calls
vi.clearAllMocks();
// Second input
fixture.componentRef.setInput('remissionReturn', mockReturn2);
fixture.detectChanges();
expect(getReceiptNumberFromReturn).toHaveBeenCalledWith(mockReturn2);
expect(getReceiptItemQuantityFromReturn).toHaveBeenCalledWith(
mockReturn2,
);
expect(getReceiptStatusFromReturn).toHaveBeenCalledWith(mockReturn2);
});
});
describe('status alias', () => {
it('should have status as an alias for completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
{
id: 2,
data: {
id: 2,
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.status()).toBe(component.completed());
expect(component.status()).toBe('Abgeschlossen');
});
it('should update status when completed changes', () => {
const initialReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', initialReturn);
fixture.detectChanges();
expect(component.status()).toBe('Offen');
const updatedReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', updatedReturn);
fixture.detectChanges();
expect(component.status()).toBe('Abgeschlossen');
});
});
describe('Edge cases', () => {
it('should handle return with deeply nested null values', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
receiptNumber: null as any,
items: null as any,
completed: null as any,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('');
expect(component.itemQuantity()).toBe(0);
expect(component.completed()).toBe('Offen');
});
it('should handle very long receipt numbers', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber:
'PREFIX-VERY-LONG-RECEIPT-NUMBER-THAT-EXCEEDS-NORMAL-LENGTH',
items: [],
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('-VERY-');
});
it('should handle large number of receipts', () => {
const receipts = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
data: {
id: i + 1,
receiptNumber: `REC-2024-${String(i + 1).padStart(3, '0')}-ABC`,
items: new Array(2),
completed: i % 2 === 0 ? '2024-01-15T10:30:00.000Z' : undefined,
},
}));
const mockReturn = createMockReturn({ receipts });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(200);
expect(component.completed()).toBe('Abgeschlossen');
expect(component.receiptNumber()).toContain('24-001');
expect(component.receiptNumber()).toContain('24-100');
describe('Constants', () => {
it('should expose ReceiptCompleteStatus constant', () => {
expect(component.ReceiptCompleteStatus).toBe(ReceiptCompleteStatus);
});
});
});

View File

@@ -3,22 +3,29 @@ import {
Component,
computed,
input,
linkedSignal,
output,
} from '@angular/core';
import { Return } from '@isa/remission/data-access';
import {
Return,
ReceiptCompleteStatusValue,
ReceiptCompleteStatus,
getReceiptStatusFromReturn,
getReceiptItemQuantityFromReturn,
getReceiptNumberFromReturn,
} from '@isa/remission/data-access';
import { RemissionReturnReceiptActionsComponent } from '@isa/remission/shared/return-receipt-actions';
/**
* Component that displays a single return receipt item in the list view.
* Shows receipt number, item quantity, and status information.
* Component that displays a list item for a remission return receipt.
* Shows the receipt number, item quantity, and status of the return.
*
* @component
* @selector remi-return-receipt-list-item
* @standalone
*
* @example
* <remi-return-receipt-list-item
* [remissionReturn]="returnData"
* [returnReceipt]="receiptData">
* </remi-return-receipt-list-item>
* <remi-return-receipt-list-item [remissionReturn]="returnData"></remi-return-receipt-list-item>
*/
@Component({
selector: 'remi-return-receipt-list-item',
@@ -26,81 +33,54 @@ import { Return } from '@isa/remission/data-access';
styleUrls: ['./return-receipt-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [],
imports: [RemissionReturnReceiptActionsComponent],
host: {
'class': 'remi-return-receipt-list-item',
'[class.remi-return-receipt-list-item--offen]': 'completed() === "Offen"',
'[class.remi-return-receipt-list-item--abgeschlossen]':
'completed() === "Abgeschlossen"',
class: 'remi-return-receipt-list-item',
},
})
export class ReturnReceiptListItemComponent {
/** Constant for receipt completion status */
ReceiptCompleteStatus = ReceiptCompleteStatus;
/**
* Required input for the return data.
* Input for the remission return data.
* @input
* @required
*/
remissionReturn = input.required<Return>();
/**
* Computed signal that extracts and formats receipt numbers from all receipts.
* Returns "Keine Belege vorhanden" if no receipts, otherwise returns formatted receipt numbers.
* @returns {string} The formatted receipt numbers or message
* Output event that emits when the list needs to be reloaded.
* @output
*/
reloadList = output<void>();
/**
* Computed signal that retrieves the receipt number from the return data.
* Uses the helper function to get the receipt number.
* @returns {string} The receipt number from the return
*/
receiptNumber = computed(() => {
const returnData = this.remissionReturn();
if (!returnData.receipts || returnData.receipts.length === 0) {
return 'Keine Belege vorhanden';
}
const receiptNumbers = returnData.receipts
.map((receipt) => receipt.data?.receiptNumber)
.filter((receiptNumber) => receiptNumber && receiptNumber.length >= 12)
.map((receiptNumber) => receiptNumber!.substring(6, 12));
return receiptNumbers.length > 0 ? receiptNumbers.join(', ') : '';
return getReceiptNumberFromReturn(returnData);
});
/**
* Computed signal that calculates the total quantity of all items across all receipts.
* @returns {number} Total quantity of items
* Computed signal that calculates the total item quantity from all receipts in the return.
* Uses the helper function to get the quantity.
* @returns {number} The total item quantity from all receipts
*/
itemQuantity = computed(() => {
const returnData = this.remissionReturn();
if (!returnData.receipts || returnData.receipts.length === 0) {
return 0;
}
return returnData.receipts.reduce((totalItems, receipt) => {
const items = receipt.data?.items;
return totalItems + (items ? items.length : 0);
}, 0);
return getReceiptItemQuantityFromReturn(returnData);
});
/**
* Computed signal that determines the completion status.
* Returns "Abgeschlossen" if any receipt is completed, "Offen" otherwise.
* @returns {'Abgeschlossen' | 'Offen'} Status text based on completion state
* Linked signal that determines the completion status of the return.
* Uses the helper function to get the status based on the return data.
* @returns {ReceiptCompleteStatusValue} The completion status of the return
*/
completed = computed(() => {
status = linkedSignal<ReceiptCompleteStatusValue>(() => {
const returnData = this.remissionReturn();
if (!returnData.receipts || returnData.receipts.length === 0) {
return 'Offen';
}
const hasCompletedReceipt = returnData.receipts.some(
(receipt) => receipt.data?.completed,
);
return hasCompletedReceipt ? 'Abgeschlossen' : 'Offen';
return getReceiptStatusFromReturn(returnData);
});
/**
* Alias for completed for backward compatibility with tests.
* @deprecated Use completed() instead
*/
status = this.completed;
}

View File

@@ -1 +1 @@
export * from './lib/remission-start-dialog/remission-start-dialog.component';
export * from './lib/remission-start-dialog/remission-start.service';

View File

@@ -1,4 +1,4 @@
import './test-mocks'; // Import mocks before anything else
import './test-mocks';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
RemissionStartDialogComponent,
@@ -63,6 +63,10 @@ describe('RemissionStartDialogComponent', () => {
fixture = TestBed.createComponent(RemissionStartDialogComponent);
component = fixture.componentInstance;
// Manually set the dialog data since it's not being injected properly
(component as any).data = mockDialogData;
fixture.detectChanges();
});
@@ -135,7 +139,7 @@ describe('RemissionStartDialogComponent', () => {
it('should close dialog when receipt type is close', async () => {
// Arrange
const closeSpy = vi.spyOn(component, 'close');
const closeSpy = vi.spyOn(component, 'onDialogClose');
const returnReceipt = { type: ReturnReceiptResultType.Close } as const;
// Act
@@ -155,7 +159,7 @@ describe('RemissionStartDialogComponent', () => {
returnId: 123,
receiptId: 456,
});
const closeSpy = vi.spyOn(component, 'close');
const closeSpy = vi.spyOn(component, 'onDialogClose');
// Act
await component.onAssignPackageNumber(packageNumber);
@@ -170,12 +174,11 @@ describe('RemissionStartDialogComponent', () => {
returnId: 123,
receiptId: 456,
});
expect(component.assignPackageRequestStatus().loading).toBe(false);
});
it('should close dialog when no package number provided', async () => {
// Arrange
const closeSpy = vi.spyOn(component, 'close');
const closeSpy = vi.spyOn(component, 'onDialogClose');
component.assignPackageStepData.set({
returnId: 123,
receiptId: 456,
@@ -191,7 +194,7 @@ describe('RemissionStartDialogComponent', () => {
it('should close dialog when no step data available', async () => {
// Arrange
const closeSpy = vi.spyOn(component, 'close');
const closeSpy = vi.spyOn(component, 'onDialogClose');
component.assignPackageStepData.set(undefined);
// Act

View File

@@ -0,0 +1,60 @@
import './test-mocks';
import { vi } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { RemissionStartService } from './remission-start.service';
import { RemissionStore } from '@isa/remission/data-access';
import { injectDialog } from '@isa/ui/dialog';
describe('RemissionStartService', () => {
let service: RemissionStartService;
let mockRemissionStore: any;
let mockDialog: any;
let mockDialogRef: any;
beforeEach(() => {
// Create mock objects
mockRemissionStore = {
startRemission: vi.fn(),
};
mockDialogRef = {
closed: of({ returnId: 'test-return-id', receiptId: 'test-receipt-id' }),
};
mockDialog = vi.fn().mockReturnValue(mockDialogRef);
// Mock the injectDialog function to return our mock dialog
vi.mocked(injectDialog).mockReturnValue(mockDialog);
TestBed.configureTestingModule({
providers: [
RemissionStartService,
{ provide: RemissionStore, useValue: mockRemissionStore },
],
});
service = TestBed.inject(RemissionStartService);
});
it('should start remission successfully when dialog returns result', async () => {
// Arrange
const returnGroup = 'test-return-group';
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
});
});
});

View File

@@ -0,0 +1,29 @@
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { injectDialog } from '@isa/ui/dialog';
import { RemissionStartDialogComponent } from './remission-start-dialog.component';
import { RemissionStore } from '@isa/remission/data-access';
@Injectable({ providedIn: 'root' })
export class RemissionStartService {
#remissionStartDialog = injectDialog(RemissionStartDialogComponent);
#remissionStore = inject(RemissionStore);
async startRemission(returnGroup: string | undefined) {
const remissionStartDialogRef = this.#remissionStartDialog({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
});
const result = await firstValueFrom(remissionStartDialogRef.closed);
if (result) {
const { returnId, receiptId } = result;
this.#remissionStore.startRemission({
returnId,
receiptId,
});
}
}
}

View File

@@ -12,7 +12,24 @@ export class MockScannerButtonComponent {
scanResult = output<string>();
}
// Mock ScannerService
export class MockScannerService {
scanBarcode = vi.fn().mockResolvedValue('mock-barcode-result');
configure = vi.fn().mockResolvedValue(undefined);
}
// Mock the entire scanner module
vi.mock('@isa/shared/scanner', () => ({
ScannerButtonComponent: MockScannerButtonComponent,
}));
ScannerService: MockScannerService,
}));
// Mock the dialog injection function
vi.mock('@isa/ui/dialog', () => ({
injectDialog: vi.fn(),
DialogContentDirective: class MockDialogContentDirective {
close = vi.fn();
data: any;
},
DialogComponent: class MockDialogComponent {},
}));

View File

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

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "remission-return-receipt-actions",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/remission/shared/return-receipt-actions/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../../coverage/libs/remission/shared/return-receipt-actions"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/remission-return-receipt-actions/remission-return-receipt-actions.component';
export * from './lib/remission-return-receipt-actions/remission-return-receipt-complete.component';

View File

@@ -0,0 +1,30 @@
<div
class="flex flex-row gap-4"
[class.justify-end]="displayDeleteAction()"
[class.justify-center]="!displayDeleteAction()"
data-which="return-receipt-actions"
>
@if (displayDeleteAction()) {
<button
uiButton
type="button"
color="secondary"
(click)="onDelete(); $event.stopPropagation(); $event.preventDefault()"
[disabled]="itemQuantity() > 0"
data-what="return-receipt-delete-button"
>
Löschen
</button>
}
<button
uiButton
type="button"
color="primary"
(click)="
onContinueRemission(); $event.stopPropagation(); $event.preventDefault()
"
data-what="return-receipt-continue-button"
>
Befüllen
</button>
</div>

View File

@@ -0,0 +1,761 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Router } from '@angular/router';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { Subject, of } from 'rxjs';
import { RemissionReturnReceiptActionsComponent } from './remission-return-receipt-actions.component';
import {
Return,
getReceiptItemQuantityFromReturn,
RemissionStore,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
import { provideLoggerContext } from '@isa/core/logging';
import { injectConfirmationDialog } from '@isa/ui/dialog';
import { injectTabId } from '@isa/core/tabs';
// Mock the helper functions and services
vi.mock('@isa/remission/data-access', async () => {
const actual = await vi.importActual('@isa/remission/data-access');
return {
...actual,
getReceiptItemQuantityFromReturn: vi.fn(),
RemissionStore: vi.fn(),
RemissionReturnReceiptService: vi.fn(),
};
});
vi.mock('@angular/router', () => ({
Router: vi.fn(),
}));
// Mock the dialog injection function
vi.mock('@isa/ui/dialog', () => ({
injectConfirmationDialog: vi.fn(),
DialogContentDirective: class MockDialogContentDirective {
close = vi.fn();
data: any;
},
DialogComponent: class MockDialogComponent {},
}));
// Mock the tab injection function
vi.mock('@isa/core/tabs', () => ({
injectTabId: vi.fn(),
}));
describe('RemissionReturnReceiptActionsComponent', () => {
let component: RemissionReturnReceiptActionsComponent;
let fixture: ComponentFixture<RemissionReturnReceiptActionsComponent>;
let mockRemissionStore: any;
let mockRemissionReturnReceiptService: any;
let mockRouter: any;
let mockDialogRef: any;
let mockInjectConfirmationDialog: any;
let mockInjectTabId: any;
const createMockReturn = (overrides: Partial<Return> = {}): Return =>
({
id: 1,
receipts: [
{ id: 101, items: [] },
{ id: 102, items: [] },
],
...overrides,
}) as Return;
const createMockReceipt = (id: number, items: any[] = []) => ({
id,
items,
});
beforeEach(async () => {
// Reset all mocks before each test
vi.clearAllMocks();
// Create mock services
mockRemissionStore = {
receiptId: vi.fn().mockReturnValue(101),
remissionStarted: vi.fn().mockReturnValue(false),
isCurrentRemission: vi.fn().mockReturnValue(false),
startRemission: vi.fn(),
clearState: vi.fn(),
};
mockRemissionReturnReceiptService = {
cancelReturnReceipt: vi.fn().mockResolvedValue({}),
cancelReturn: vi.fn().mockResolvedValue({}),
};
mockRouter = {
navigate: vi.fn().mockResolvedValue(true),
};
mockDialogRef = {
closed: new Subject(),
};
// Mock the injected functions
mockInjectConfirmationDialog = vi.fn().mockReturnValue(mockDialogRef);
mockInjectTabId = vi.fn().mockReturnValue('test-tab-id');
// Setup the mocks for the import functions
vi.mocked(injectConfirmationDialog).mockReturnValue(
mockInjectConfirmationDialog,
);
vi.mocked(injectTabId).mockReturnValue(mockInjectTabId);
await TestBed.configureTestingModule({
imports: [
HttpClientTestingModule,
RemissionReturnReceiptActionsComponent,
],
providers: [
{ provide: RemissionStore, useValue: mockRemissionStore },
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionReturnReceiptService,
},
{ provide: Router, useValue: mockRouter },
provideLoggerContext({ component: 'Test' }),
],
}).compileComponents();
fixture = TestBed.createComponent(RemissionReturnReceiptActionsComponent);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
expect(component).toBeTruthy();
});
it('should have remissionReturn as required input', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.remissionReturn()).toBeDefined();
expect(component.remissionReturn()).toEqual(mockReturn);
});
});
describe('Computed signals', () => {
describe('returnId', () => {
it('should return the ID from the remission return', () => {
const mockReturn = createMockReturn({ id: 123 });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.returnId()).toBe(123);
});
});
describe('receiptIds', () => {
it('should return array of receipt IDs', () => {
const mockReturn = createMockReturn({
receipts: [createMockReceipt(101), createMockReceipt(102)],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptIds()).toEqual([101, 102]);
});
it('should return empty array when no receipts', () => {
const mockReturn = createMockReturn({ receipts: undefined });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptIds()).toEqual([]);
});
});
describe('firstReceiptId', () => {
it('should return first receipt ID when receipts exist', () => {
const mockReturn = createMockReturn({
receipts: [createMockReceipt(101), createMockReceipt(102)],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.firstReceiptId()).toBe(101);
});
it('should return undefined when no receipts exist', () => {
const mockReturn = createMockReturn({ receipts: [] });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.firstReceiptId()).toBeUndefined();
});
});
describe('itemQuantity', () => {
it('should return item quantity from helper function', () => {
const mockReturn = createMockReturn();
const expectedQuantity = 5;
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(
expectedQuantity,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(expectedQuantity);
expect(getReceiptItemQuantityFromReturn).toHaveBeenCalledWith(
mockReturn,
);
});
});
describe('isCurrentRemission', () => {
it('should return true when return is current remission', () => {
const mockReturn = createMockReturn({
id: 123,
receipts: [createMockReceipt(101)],
});
mockRemissionStore.receiptId.mockReturnValue(101);
mockRemissionStore.isCurrentRemission.mockReturnValue(true);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.isCurrentRemission()).toBe(true);
expect(mockRemissionStore.isCurrentRemission).toHaveBeenCalledWith({
returnId: 123,
receiptId: 101,
});
});
it('should return false when receipt is not found in current receipts', () => {
const mockReturn = createMockReturn({
receipts: [createMockReceipt(101)],
});
mockRemissionStore.receiptId.mockReturnValue(999); // Different receipt ID
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.isCurrentRemission()).toBe(false);
});
});
});
describe('Template rendering', () => {
beforeEach(() => {
// Setup default mock values for template rendering tests
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(3);
});
it('should render delete button', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const deleteButton = fixture.nativeElement.querySelector(
'button[color="secondary"]',
);
expect(deleteButton).toBeTruthy();
expect(deleteButton.textContent.trim()).toBe('Löschen');
});
it('should render continue button', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const continueButton = fixture.nativeElement.querySelector(
'button[color="primary"]',
);
expect(continueButton).toBeTruthy();
expect(continueButton.textContent.trim()).toBe('Befüllen');
});
it('should have correct data attributes for E2E testing', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const container = fixture.nativeElement.querySelector(
'[data-which="return-receipt-actions"]',
);
expect(container).toBeTruthy();
const deleteButton = fixture.nativeElement.querySelector(
'[data-what="return-receipt-delete-button"]',
);
expect(deleteButton).toBeTruthy();
const continueButton = fixture.nativeElement.querySelector(
'[data-what="return-receipt-continue-button"]',
);
expect(continueButton).toBeTruthy();
});
it('should disable delete button when itemQuantity is greater than 0', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(5);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const deleteButton = fixture.nativeElement.querySelector(
'button[color="secondary"]',
);
expect(deleteButton.disabled).toBe(true);
});
it('should enable delete button when itemQuantity is 0', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(0);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const deleteButton = fixture.nativeElement.querySelector(
'button[color="secondary"]',
);
expect(deleteButton.disabled).toBe(false);
});
it('should never disable continue button', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(5);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const continueButton = fixture.nativeElement.querySelector(
'button[color="primary"]',
);
expect(continueButton.disabled).toBe(false);
});
});
describe('Component methods', () => {
describe('onDelete', () => {
it('should call service methods and emit reloadData when deleting return', async () => {
const mockReturn = createMockReturn({
id: 123,
receipts: [createMockReceipt(101), createMockReceipt(102)],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Mock the isCurrentRemission method to avoid the signal error
const isCurrentRemissionSpy = vi
.spyOn(component, 'isCurrentRemission')
.mockReturnValue(false);
await component.onDelete();
// Should cancel each receipt
expect(
mockRemissionReturnReceiptService.cancelReturnReceipt,
).toHaveBeenCalledTimes(2);
expect(
mockRemissionReturnReceiptService.cancelReturnReceipt,
).toHaveBeenNthCalledWith(1, {
returnId: 123,
receiptId: 101,
});
expect(
mockRemissionReturnReceiptService.cancelReturnReceipt,
).toHaveBeenNthCalledWith(2, {
returnId: 123,
receiptId: 102,
});
// Should cancel the return
expect(
mockRemissionReturnReceiptService.cancelReturn,
).toHaveBeenCalledWith({
returnId: 123,
});
// Should emit reload data
expect(reloadDataSpy).toHaveBeenCalled();
isCurrentRemissionSpy.mockRestore();
});
it('should clear state when deleting current remission', async () => {
const mockReturn = createMockReturn({
id: 123,
receipts: [createMockReceipt(101)],
});
// Set up the component to think this is the current remission
const isCurrentRemissionSpy = vi
.spyOn(component, 'isCurrentRemission')
.mockReturnValue(true);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
await component.onDelete();
expect(mockRemissionStore.clearState).toHaveBeenCalled();
isCurrentRemissionSpy.mockRestore();
});
it('should handle errors during deletion', async () => {
const mockReturn = createMockReturn();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const error = new Error('Network error');
// Mock the isCurrentRemission method to avoid the signal error
const isCurrentRemissionSpy = vi
.spyOn(component, 'isCurrentRemission')
.mockReturnValue(false);
mockRemissionReturnReceiptService.cancelReturnReceipt.mockRejectedValue(
error,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
await component.onDelete();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error deleting return receipt:',
error,
);
expect(reloadDataSpy).toHaveBeenCalled(); // Should still emit even on error
consoleErrorSpy.mockRestore();
isCurrentRemissionSpy.mockRestore();
});
});
describe('onContinueRemission', () => {
it('should start remission and navigate when no remission is started', async () => {
const mockReturn = createMockReturn({
id: 123,
receipts: [createMockReceipt(101)],
});
mockRemissionStore.remissionStarted.mockReturnValue(false);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
await component.onContinueRemission();
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 123,
receiptId: 101,
});
expect(mockRouter.navigate).toHaveBeenCalledWith([
'/',
'test-tab-id',
'remission',
]);
});
it('should not start remission when no first receipt ID exists', async () => {
const mockReturn = createMockReturn({ receipts: [] });
mockRemissionStore.remissionStarted.mockReturnValue(false);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
await component.onContinueRemission();
expect(mockRemissionStore.startRemission).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledWith([
'/',
'test-tab-id',
'remission',
]);
});
it('should show confirmation dialog when remission is started but not current', async () => {
const mockReturn = createMockReturn({
id: 123,
receipts: [createMockReceipt(101)],
});
mockRemissionStore.remissionStarted.mockReturnValue(true);
// Mock isCurrentRemission to return false
const isCurrentRemissionSpy = vi
.spyOn(component, 'isCurrentRemission')
.mockReturnValue(false);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
// Mock dialog confirmation result
const dialogResultSubject = new Subject();
mockDialogRef.closed = dialogResultSubject.asObservable();
const continuePromise = component.onContinueRemission();
// Simulate user confirming the dialog
dialogResultSubject.next({ confirmed: true });
dialogResultSubject.complete();
await continuePromise;
expect(mockInjectConfirmationDialog).toHaveBeenCalledWith({
title: 'Bereits geöffneter Warenbegleitschein',
width: '30rem',
data: {
message:
'Möchten Sie wirklich einen neuen öffnen, oder möchten Sie zuerst den aktuellen abschließen?',
closeText: 'Aktuellen bearbeiten',
confirmText: 'Neuen öffnen',
},
});
expect(mockRemissionStore.clearState).toHaveBeenCalled();
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 123,
receiptId: 101,
});
isCurrentRemissionSpy.mockRestore();
});
it('should not start new remission when dialog is cancelled', async () => {
const mockReturn = createMockReturn({
id: 123,
receipts: [createMockReceipt(101)],
});
mockRemissionStore.remissionStarted.mockReturnValue(true);
// Mock isCurrentRemission to return false
const isCurrentRemissionSpy = vi
.spyOn(component, 'isCurrentRemission')
.mockReturnValue(false);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
// Mock dialog cancellation result
const dialogResultSubject = new Subject();
mockDialogRef.closed = dialogResultSubject.asObservable();
const continuePromise = component.onContinueRemission();
// Simulate user cancelling the dialog
dialogResultSubject.next({ confirmed: false });
dialogResultSubject.complete();
await continuePromise;
expect(mockRemissionStore.clearState).not.toHaveBeenCalled();
expect(mockRemissionStore.startRemission).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledWith([
'/',
'test-tab-id',
'remission',
]);
isCurrentRemissionSpy.mockRestore();
});
it('should navigate directly when current remission matches', async () => {
const mockReturn = createMockReturn({
id: 123,
receipts: [createMockReceipt(101)],
});
mockRemissionStore.remissionStarted.mockReturnValue(true);
// Mock isCurrentRemission to return true
const isCurrentRemissionSpy = vi
.spyOn(component, 'isCurrentRemission')
.mockReturnValue(true);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
await component.onContinueRemission();
expect(mockInjectConfirmationDialog).not.toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledWith([
'/',
'test-tab-id',
'remission',
]);
isCurrentRemissionSpy.mockRestore();
});
});
describe('navigateToRemissionList', () => {
it('should navigate to correct remission route', async () => {
await component.navigateToRemissionList();
expect(mockRouter.navigate).toHaveBeenCalledWith([
'/',
'test-tab-id',
'remission',
]);
});
});
});
describe('Component reactivity', () => {
it('should update computed signals when input changes', () => {
const initialReturn = createMockReturn({ id: 1 });
const updatedReturn = createMockReturn({ id: 2 });
// Mock return values for initial state
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(3);
fixture.componentRef.setInput('remissionReturn', initialReturn);
fixture.detectChanges();
expect(component.returnId()).toBe(1);
expect(component.itemQuantity()).toBe(3);
// Mock return values for updated state
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(7);
fixture.componentRef.setInput('remissionReturn', updatedReturn);
fixture.detectChanges();
expect(component.returnId()).toBe(2);
expect(component.itemQuantity()).toBe(7);
});
});
describe('User interactions', () => {
beforeEach(() => {
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(0);
});
it('should call onDelete when delete button is clicked', async () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const onDeleteSpy = vi.spyOn(component, 'onDelete').mockResolvedValue();
const deleteButton = fixture.nativeElement.querySelector(
'button[color="secondary"]',
);
deleteButton.click();
expect(onDeleteSpy).toHaveBeenCalled();
});
it('should call onContinueRemission when continue button is clicked', async () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const onContinueRemissionSpy = vi
.spyOn(component, 'onContinueRemission')
.mockResolvedValue();
const continueButton = fixture.nativeElement.querySelector(
'button[color="primary"]',
);
continueButton.click();
expect(onContinueRemissionSpy).toHaveBeenCalled();
});
it('should prevent event propagation and default behavior on delete click', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
vi.spyOn(component, 'onDelete').mockResolvedValue();
const deleteButton = fixture.nativeElement.querySelector(
'button[color="secondary"]',
);
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation');
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
deleteButton.dispatchEvent(event);
expect(stopPropagationSpy).toHaveBeenCalled();
expect(preventDefaultSpy).toHaveBeenCalled();
});
it('should prevent event propagation and default behavior on continue click', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
vi.spyOn(component, 'onContinueRemission').mockResolvedValue();
const continueButton = fixture.nativeElement.querySelector(
'button[color="primary"]',
);
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation');
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
continueButton.dispatchEvent(event);
expect(stopPropagationSpy).toHaveBeenCalled();
expect(preventDefaultSpy).toHaveBeenCalled();
});
});
describe('Output events', () => {
it('should emit reloadData on successful deletion', async () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
await component.onDelete();
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should emit reloadData even on deletion error', async () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
mockRemissionReturnReceiptService.cancelReturnReceipt.mockRejectedValue(
new Error('Network error'),
);
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
await component.onDelete();
expect(reloadDataSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,221 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
} from '@angular/core';
import {
Return,
getReceiptItemQuantityFromReturn,
RemissionStore,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
import { logger } from '@isa/core/logging';
import { Router } from '@angular/router';
import { injectConfirmationDialog } from '@isa/ui/dialog';
import { injectTabId } from '@isa/core/tabs';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'lib-remission-return-receipt-actions',
templateUrl: './remission-return-receipt-actions.component.html',
styleUrl: './remission-return-receipt-actions.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonComponent],
})
export class RemissionReturnReceiptActionsComponent {
/**
* Remission store for managing remission state.
*/
#remissionStore = inject(RemissionStore);
/**
* Service for handling remission return receipt operations.
*/
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/** Angular Router for navigation */
router = inject(Router);
/**
* Dialog service for confirmation prompts.
*/
#confirmDialog = injectConfirmationDialog();
/**
* Injects the current activated tab ID as a signal.
* This is used to determine if the current remission matches the active tab.
*/
activatedTabId = injectTabId();
/** Logger for this component */
#logger = logger(() => ({
component: 'RemissionReturnReceiptActionsComponent',
}));
/** displayDeleteAction - Determines if the delete action should be displayed */
displayDeleteAction = input<boolean>(true);
/**
* Input for the remission return data.
* @input
*/
remissionReturn = input.required<Return>();
/**
* Output event that emits when the list needs to be reloaded.
* @output
*/
reloadData = output<void>();
/**
* Computed signal that retrieves the return ID from the remission return data.
* This is used to identify the specific return for operations like deletion or continuation.
* @returns {number} The ID of the remission return
*/
returnId = computed(() => this.remissionReturn().id);
/**
* Computed signal that retrieves all receipt IDs associated with the remission return.
* This is used to manage multiple receipts within a single return.
* @returns {Array<number>} Array of receipt IDs
*/
receiptIds = computed(() => {
return this.remissionReturn().receipts?.map((r) => r.id) || [];
});
/**
* Computed signal that retrieves the first receipt ID from the remission return.
* This is useful for operations that require a single receipt ID, such as starting a remission.
* @returns {number | undefined} The first receipt ID or undefined if no receipts exist
*/
firstReceiptId = computed(() => {
const receiptIds = this.receiptIds();
return receiptIds.length > 0 ? receiptIds[0] : undefined;
});
/**
* Computed signal that calculates the total item quantity from all receipts in the return.
* Uses the helper function to get the quantity.
* @returns {number} The total item quantity from all receipts
*/
itemQuantity = computed(() => {
const returnData = this.remissionReturn();
return getReceiptItemQuantityFromReturn(returnData);
});
/**
* Computed signal that checks if the current remission is the one being displayed.
* This is used to determine if the return is currently active in the remission flow.
* @returns {boolean} True if the current remission matches, false otherwise
*/
isCurrentRemission = computed(() => {
const returnId = this.returnId();
const receiptIds = this.receiptIds();
const currentReceiptId = this.#remissionStore?.receiptId();
const currentlyOpenReceiptId = receiptIds?.find(
(id) => currentReceiptId === id,
);
if (!currentlyOpenReceiptId) {
return false;
}
return this.#remissionStore.isCurrentRemission({
returnId,
receiptId: currentlyOpenReceiptId,
});
});
/**
* Method to delete the return receipt.
* This method handles the deletion of the return and its associated receipts.
* It checks if the return is the current remission and clears the store state if necessary.
* Emits the reloadList event after successful deletion to refresh the list.
* @returns {Promise<void>} A promise that resolves when the deletion is complete
*/
async onDelete(): Promise<void> {
try {
const returnId = this.returnId();
const receiptIds = this.receiptIds();
for (const receiptId of receiptIds) {
if (this.isCurrentRemission()) {
this.#remissionStore.clearState();
}
await this.#remissionReturnReceiptService.cancelReturnReceipt({
returnId,
receiptId,
});
}
await this.#remissionReturnReceiptService.cancelReturn({
returnId,
});
} catch (error) {
this.#logger.error('Error deleting return receipt', error);
console.error('Error deleting return receipt:', error);
}
this.reloadData.emit();
}
/**
* Method to continue with the remission process.
* If the remission is already started, it navigates to the remission list.
* If not, it checks if there is a first receipt ID and starts the remission with it.
* If a remission is already in progress, it prompts the user to either continue with the current remission or start a new one.
* @returns {Promise<void>} A promise that resolves when navigation is complete
*/
async onContinueRemission() {
if (!this.#remissionStore.remissionStarted()) {
const firstReceiptId = this.firstReceiptId();
if (firstReceiptId) {
this.#remissionStore.startRemission({
returnId: this.returnId(),
receiptId: firstReceiptId,
});
return await this.navigateToRemissionList();
}
} else if (!this.isCurrentRemission()) {
const dialogRef = this.#confirmDialog({
title: 'Bereits geöffneter Warenbegleitschein',
width: '30rem',
data: {
message:
'Möchten Sie wirklich einen neuen öffnen, oder möchten Sie zuerst den aktuellen abschließen?',
closeText: 'Aktuellen bearbeiten',
confirmText: 'Neuen öffnen',
},
});
const dialogResult = await firstValueFrom(dialogRef.closed);
// Neuen WBS öffnen
if (dialogResult?.confirmed) {
this.#remissionStore.clearState(); // Alte Remission im Store zurücksetzen
const firstReceiptId = this.firstReceiptId();
if (firstReceiptId) {
this.#remissionStore.startRemission({
returnId: this.returnId(),
receiptId: firstReceiptId,
});
}
}
}
await this.navigateToRemissionList();
}
/**
* Navigates to the remission list based on the current activated tab ID.
* This method is used to redirect the user to the remission list after completing or starting a remission.
* @returns {Promise<void>} A promise that resolves when navigation is complete
*/
async navigateToRemissionList() {
await this.router.navigate(['/', this.activatedTabId(), 'remission']);
}
}

View File

@@ -0,0 +1,12 @@
<button
[disabled]="completingRemission() || itemsLength() === 0"
[pending]="completingRemission()"
color="brand"
size="large"
class="fixed right-6 bottom-6"
uiButton
type="button"
(click)="completeRemission()"
>
Wanne abschließen
</button>

View File

@@ -0,0 +1,221 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Router } from '@angular/router';
import { Subject, of } from 'rxjs';
import { RemissionReturnReceiptCompleteComponent } from './remission-return-receipt-complete.component';
import {
RemissionReturnReceiptService,
RemissionStore,
} from '@isa/remission/data-access';
import { RemissionStartService } from '@isa/remission/shared/remission-start-dialog';
import { provideLoggerContext } from '@isa/core/logging';
// Mock the services
vi.mock('@isa/remission/data-access', async () => {
const actual = await vi.importActual('@isa/remission/data-access');
return {
...actual,
RemissionReturnReceiptService: vi.fn(),
RemissionStore: vi.fn(),
};
});
vi.mock('@isa/remission/shared/remission-start-dialog', () => ({
RemissionStartService: vi.fn(),
}));
vi.mock('@angular/router', () => ({
Router: vi.fn(),
}));
// Mock the dialog injection function
vi.mock('@isa/ui/dialog', () => ({
injectConfirmationDialog: vi.fn(),
}));
// Mock the tab injection function
vi.mock('@isa/core/tabs', () => ({
injectTabId: vi.fn(),
}));
describe('RemissionReturnReceiptCompleteComponent', () => {
let component: RemissionReturnReceiptCompleteComponent;
let fixture: ComponentFixture<RemissionReturnReceiptCompleteComponent>;
let mockRemissionReturnReceiptService: any;
let mockRemissionStartService: any;
let mockRouter: any;
let mockRemissionStore: any;
let mockDialogRef: any;
let mockInjectConfirmationDialog: any;
let mockInjectTabId: any;
beforeEach(async () => {
// Reset all mocks before each test
vi.clearAllMocks();
// Create mock services
mockRemissionReturnReceiptService = {
completeReturnReceiptAndReturn: vi.fn(),
completeReturnGroup: vi.fn(),
};
mockRemissionStartService = {
startRemission: vi.fn(),
};
mockRouter = {
navigate: vi.fn().mockResolvedValue(true),
};
mockRemissionStore = {
clearState: vi.fn(),
};
mockDialogRef = {
closed: new Subject(),
};
// Mock the injected functions
mockInjectConfirmationDialog = vi.fn().mockReturnValue(mockDialogRef);
mockInjectTabId = vi.fn().mockReturnValue('test-tab-id');
// Setup the mocks for the import functions
const { injectConfirmationDialog } = await import('@isa/ui/dialog');
const { injectTabId } = await import('@isa/core/tabs');
vi.mocked(injectConfirmationDialog).mockReturnValue(
mockInjectConfirmationDialog,
);
vi.mocked(injectTabId).mockReturnValue(mockInjectTabId);
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptCompleteComponent],
providers: [
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionReturnReceiptService,
},
{ provide: RemissionStartService, useValue: mockRemissionStartService },
{ provide: Router, useValue: mockRouter },
{ provide: RemissionStore, useValue: mockRemissionStore },
provideLoggerContext({ component: 'Test' }),
],
}).compileComponents();
fixture = TestBed.createComponent(RemissionReturnReceiptCompleteComponent);
component = fixture.componentInstance;
// Set required inputs
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
fixture.componentRef.setInput('itemsLength', 5);
fixture.detectChanges();
});
describe('Component Setup', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have correct initial values', () => {
expect(component.returnId()).toBe(123);
expect(component.receiptId()).toBe(456);
expect(component.itemsLength()).toBe(5);
expect(component.completingRemission()).toBe(false);
});
});
describe('Component methods', () => {
describe('completeSingleReturnReceipt', () => {
it('should complete single return receipt successfully', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: 'RG001' };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
// Act
const result = await component.completeSingleReturnReceipt();
// Assert
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
expect(mockRemissionStore.clearState).toHaveBeenCalled();
expect(result).toEqual(mockReturn);
});
});
describe('completeRemission', () => {
it('should complete remission without return group', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: null };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(component.completingRemission()).toBe(false);
expect(mockInjectConfirmationDialog).not.toHaveBeenCalled();
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should prevent multiple completion attempts', async () => {
// Arrange
component.completingRemission.set(true);
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).not.toHaveBeenCalled();
expect(reloadDataSpy).not.toHaveBeenCalled();
});
it('should emit reloadData on successful completion', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: null };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(reloadDataSpy).toHaveBeenCalled();
});
});
describe('navigateToRemissionList', () => {
it('should navigate to remission list', async () => {
// Act
await component.navigateToRemissionList();
// Assert
expect(mockRouter.navigate).toHaveBeenCalledWith([
'/',
'test-tab-id',
'remission',
]);
});
});
});
});

View File

@@ -0,0 +1,173 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
signal,
} from '@angular/core';
import { Router } from '@angular/router';
import { logger } from '@isa/core/logging';
import { injectTabId } from '@isa/core/tabs';
import {
RemissionReturnReceiptService,
RemissionStore,
Return,
} from '@isa/remission/data-access';
import { RemissionStartService } from '@isa/remission/shared/remission-start-dialog';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectConfirmationDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'lib-remission-return-receipt-complete',
templateUrl: './remission-return-receipt-complete.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonComponent],
})
export class RemissionReturnReceiptCompleteComponent {
/** Logger for this component */
#logger = logger(() => ({
component: 'RemissionReturnReceiptCompleteComponent',
}));
/** Service for managing remission return receipt operations */
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/** Service for starting a new remission */
#remissionStartService = inject(RemissionStartService);
/** Angular Router for navigation */
router = inject(Router);
/**
* Injects a confirmation dialog service for user interactions.
* This dialog is used to confirm actions like completing a return.
* The dialog can be customized with title and data options.
* @example
* const dialogRef = this.#dialog({
* title: 'Wanne abgeschlossen',
* data: {
* message: 'Legen Sie abschließend den “Blank Beizettel” in die abgeschlossene Wanne.',
* closeText: 'Neue Wanne',
* confirmText: 'Beenden',
* },
* });
*/
#confirmDialog = injectConfirmationDialog();
/**
* Injects the current activated tab ID as a signal.
* This is used to determine if the current remission matches the active tab.
*/
activatedTabId = injectTabId();
/** Instance of the RemissionStore for managing remission state */
store = inject(RemissionStore);
/**
* Required input for the return ID.
* Automatically coerced to a number from string input.
* @input
* @required
*/
returnId = input.required<number>();
/**
* Required input for the receipt ID.
* Automatically coerced to a number from string input.
* @input
* @required
*/
receiptId = input.required<number>();
/**
* Required input for the number of items in the return.
* This is used to display the count of items in the return receipt.
* @input
* @required
*/
itemsLength = input.required<number>();
/**
* Output event that emits when the list needs to be reloaded.
* This is used to refresh the remission list after completing a return.
* @output
*/
reloadData = output<void>();
/**
* Signal to track if the remission is currently being completed.
* This prevents multiple submissions while a completion operation is in progress.
*/
completingRemission = signal(false);
/**
* Completes the current remission return receipt.
* This method handles the completion logic, including confirmation dialogs and navigation.
* It also emits a reload event to refresh the remission list.
* @returns {Promise<Return>} The completed return data
*/
async completeSingleReturnReceipt(): Promise<Return> {
const completedReturn =
await this.#remissionReturnReceiptService.completeReturnReceiptAndReturn({
returnId: this.returnId(),
receiptId: this.receiptId(),
});
this.store.clearState();
return completedReturn;
}
/**
* Handles the completion of the remission return receipt.
* It checks if the remission is already being completed to avoid duplicate actions.
* If not, it proceeds to complete the remission and handle the post-completion logic.
*/
async completeRemission() {
if (this.completingRemission()) {
return;
}
this.completingRemission.set(true);
try {
const completedReturn = await this.completeSingleReturnReceipt();
const returnGroup = completedReturn?.returnGroup;
if (returnGroup) {
const dialogRef = this.#confirmDialog({
title: 'Wanne abgeschlossen',
width: '30rem',
data: {
message:
'Legen Sie abschließend den “Blank Beizettel” in die abgeschlossene Wanne. Der Warenbegleitschein wird nach Abschluss der Remission versendet. Zum Öffnen eines neuen Warenbegleitscheins setzen Sie die Remission fort.',
closeText: 'Neue Wanne',
confirmText: 'Beenden',
},
});
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult?.confirmed) {
// Beenden - Remission abschließen Flow
await this.#remissionReturnReceiptService.completeReturnGroup({
returnGroup,
});
} else {
// Starten - Neue Wanne Flow
await this.#remissionStartService.startRemission(returnGroup);
await this.navigateToRemissionList();
}
}
this.reloadData.emit();
} catch (error) {
this.#logger.error('Failed to complete remission', error);
}
this.completingRemission.set(false);
}
/**
* Navigates to the remission list.
*/
async navigateToRemissionList() {
await this.router.navigate(['/', this.activatedTabId(), 'remission']);
}
}

View File

@@ -0,0 +1,41 @@
import { vi } from 'vitest';
import { Component, output } from '@angular/core';
import { of } from 'rxjs';
// Mock ScannerButtonComponent
// eslint-disable-next-line @angular-eslint/component-selector
@Component({
selector: 'isa-scanner-button',
template: '<button>Scan</button>',
standalone: true,
})
export class MockScannerButtonComponent {
scanResult = output<string>();
}
// Mock ScannerService
export class MockScannerService {
scanBarcode = vi.fn().mockResolvedValue('mock-barcode-result');
configure = vi.fn().mockResolvedValue(undefined);
}
// Mock the entire scanner module
vi.mock('@isa/shared/scanner', () => ({
ScannerButtonComponent: MockScannerButtonComponent,
ScannerService: MockScannerService,
}));
// Mock the dialog injection function
vi.mock('@isa/ui/dialog', () => ({
injectDialog: vi.fn(),
injectConfirmationDialog: vi.fn(() =>
vi.fn(() => ({
closed: of({ confirmed: false }),
})),
),
DialogContentDirective: class MockDialogContentDirective {
close = vi.fn();
data: any;
},
DialogComponent: class MockDialogComponent {},
}));

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"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",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,29 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir:
'../../../../node_modules/.vite/libs/remission/shared/return-receipt-actions',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory:
'../../../../coverage/libs/remission/shared/return-receipt-actions',
provider: 'v8' as const,
},
},
}));

View File

@@ -1,17 +1,24 @@
<div class="w-full flex flex-row justify-between items-start">
<filter-search-bar-input
class="flex flex-row gap-4 h-12"
[appearance]="'results'"
inputKey="qs"
(triggerSearch)="triggerSearch.emit($event)"
data-what="search-input"
></filter-search-bar-input>
<div
class="w-full flex flex-row justify-between items-start"
[class.empty-filter-input]="!hasFilter() && !hasInput()"
>
@if (hasInput()) {
<filter-search-bar-input
class="flex flex-row gap-4 h-12"
[appearance]="'results'"
[inputKey]="inputKey()"
(triggerSearch)="triggerSearch.emit($event)"
data-what="search-input"
></filter-search-bar-input>
}
<div class="flex flex-row gap-4 items-center">
<filter-filter-menu-button
(applied)="triggerSearch.emit('filter')"
[rollbackOnClose]="true"
></filter-filter-menu-button>
@if (hasFilter()) {
<filter-filter-menu-button
(applied)="triggerSearch.emit('filter')"
[rollbackOnClose]="true"
></filter-filter-menu-button>
}
@if (mobileBreakpoint()) {
<ui-icon-button
@@ -23,7 +30,9 @@
></ui-icon-button>
} @else {
<filter-order-by-toolbar
[class.empty-filter-input-width]="!hasFilter() && !hasInput()"
(toggled)="triggerSearch.emit('order-by')"
data-what="sort-toolbar"
></filter-order-by-toolbar>
}
</div>

View File

@@ -1,3 +1,11 @@
.filter-controls-panel {
@apply flex flex-col gap-4;
@apply w-full flex flex-col gap-4;
}
.empty-filter-input {
@apply justify-end;
}
.empty-filter-input-width {
@apply w-[44.375rem];
}

View File

@@ -1,32 +1,36 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
linkedSignal,
output,
signal,
ViewEncapsulation,
} from '@angular/core';
import { SearchBarInputComponent } from '../inputs';
import { FilterMenuButtonComponent } from '../menus/filter-menu';
import { provideIcons } from '@ng-icons/core';
import { isaActionFilter, isaActionSort } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import { OrderByToolbarComponent } from '../order-by';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { SearchTrigger } from '../types';
import { InputType, SearchTrigger } from '../types';
import { FilterService, TextFilterInput } from '../core';
import { SearchBarInputComponent } from '../inputs';
/**
* Filter controls panel component that provides a unified interface for search and filtering operations.
*
*
* This component combines search input, filter menu, and sorting controls into a responsive panel.
* It adapts its layout based on screen size, showing/hiding controls appropriately for mobile and desktop views.
*
*
* @example
* ```html
* <filter-controls-panel
* <filter-controls-panel
* (triggerSearch)="handleSearch($event)">
* </filter-controls-panel>
* ```
*
*
* Features:
* - Responsive design that adapts to mobile/desktop layouts
* - Integrated search bar with scanner support
@@ -53,11 +57,22 @@ import { SearchTrigger } from '../types';
providers: [provideIcons({ isaActionSort, isaActionFilter })],
})
export class FilterControlsPanelComponent {
/**
* Service for managing filter state and operations.
*/
#filterService = inject(FilterService);
/**
* The unique key identifier for this input in the filter system.
* @default 'qs'
*/
inputKey = signal('qs');
/**
* Output event that emits when any search action is triggered.
* Provides the specific SearchTrigger type to indicate how the search was initiated:
* - 'input': Text input or search button
* - 'filter': Filter menu changes
* - 'filter': Filter menu changes
* - 'order-by': Sort order changes
* - 'scan': Barcode scan
*/
@@ -75,4 +90,26 @@ export class FilterControlsPanelComponent {
* Linked to mobileBreakpoint to automatically adjust when screen size changes.
*/
showOrderByToolbarMobile = linkedSignal(() => !this.mobileBreakpoint());
/**
* Computed signal that determines if the search input is present in the filter inputs.
* This checks if there is a TextFilterInput with the specified inputKey.
* Used to conditionally render the search input in the template.
*/
hasInput = computed(() => {
const inputs = this.#filterService.inputs();
const input = inputs.find(
(input) => input.key === this.inputKey() && input.type === InputType.Text,
) as TextFilterInput;
return !!input;
});
/**
* Computed signal that checks if there are any active filters applied.
* This is determined by checking if there are any inputs of types other than Text.
*/
hasFilter = computed(() => {
const inputs = this.#filterService.inputs();
return inputs.some((input) => input.type !== InputType.Text);
});
}

View File

@@ -6,6 +6,7 @@ import {
injectMessageDialog,
injectTextInputDialog,
injectNumberInputDialog,
injectConfirmationDialog,
} from './injects';
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
import { DialogComponent } from './dialog.component';
@@ -15,6 +16,7 @@ import { DialogContentDirective } from './dialog-content.directive';
import { TextInputDialogComponent } from './text-input-dialog/text-input-dialog.component';
import { FeedbackDialogComponent } from './feedback-dialog/feedback-dialog.component';
import { NumberInputDialogComponent } from './number-input-dialog/number-input-dialog.component';
import { ConfirmationDialogComponent } from './confirmation-dialog/confirmation-dialog.component';
// Test component extending DialogContentDirective for testing
@Component({ template: '' })
@@ -218,4 +220,23 @@ describe('Dialog Injects', () => {
expect(injector.get(DIALOG_CONTENT)).toBe(FeedbackDialogComponent);
});
});
describe('injectConfirmationDialog', () => {
it('should create a dialog injector for ConfirmationDialogComponent', () => {
// Act
const openConfirmationDialog = TestBed.runInInjectionContext(() =>
injectConfirmationDialog(),
);
openConfirmationDialog({
data: {
message: 'Test message',
},
});
// Assert
const callOptions = mockDialogOpen.mock.calls[0][1];
const injector = callOptions.injector;
expect(injector.get(DIALOG_CONTENT)).toBe(ConfirmationDialogComponent);
});
});
});

View File

@@ -12,6 +12,10 @@ import {
FeedbackDialogComponent,
FeedbackDialogData,
} from './feedback-dialog/feedback-dialog.component';
import {
ConfirmationDialogComponent,
ConfirmationDialogData,
} from './confirmation-dialog/confirmation-dialog.component';
export interface InjectDialogOptions {
/** Optional title override for the dialog */
@@ -114,6 +118,15 @@ export function injectDialog<C extends DialogContentDirective<any, any>>(
};
}
/**
* Convenience function that returns a pre-configured ConfirmationDialog injector
* @param options Optional configuration for the dialog
* @returns A function to open a confirmation dialog
*/
export const injectConfirmationDialog = (
options?: OpenDialogOptions<ConfirmationDialogData>,
) => injectDialog(ConfirmationDialogComponent, options);
/**
* Convenience function that returns a pre-configured MessageDialog injector
* @returns A function to open a message dialog

57
package-lock.json generated
View File

@@ -1095,18 +1095,6 @@
"linux"
]
},
"node_modules/@angular/build/node_modules/@types/node": {
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz",
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.10.0"
}
},
"node_modules/@angular/build/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1336,6 +1324,7 @@
"version": "20.1.2",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.1.2.tgz",
"integrity": "sha512-NMSDavN+CJYvSze6wq7DpbrUA/EqiAD7GQoeJtuOknzUpPlWQmFOoHzTMKW+S34XlNEw+YQT0trv3DKcrE+T/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "7.28.0",
@@ -11931,17 +11920,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/retry": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
@@ -14679,6 +14657,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -15153,6 +15132,7 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
@@ -16370,7 +16350,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.2"
@@ -19082,7 +19062,7 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -27582,17 +27562,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -27632,6 +27601,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -27686,6 +27656,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/regenerate": {
@@ -28667,7 +28638,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/sass": {
@@ -29208,6 +29179,7 @@
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -31745,7 +31717,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -31805,15 +31777,6 @@
"node": "*"
}
},
"node_modules/undici-types": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",

View File

@@ -86,6 +86,9 @@
"@isa/remission/shared/remission-start-dialog": [
"libs/remission/shared/remission-start-dialog/src/index.ts"
],
"@isa/remission/shared/return-receipt-actions": [
"libs/remission/shared/return-receipt-actions/src/index.ts"
],
"@isa/remission/shared/search-item-to-remit-dialog": [
"libs/remission/shared/search-item-to-remit-dialog/src/index.ts"
],