Merged PR 1897: #5236 #4771 Abteilungsremission

- feat(remission-list): Added Tooltip and Static Toolbar
- Merge branch 'develop' into feature/5236-Remission-Abteilungsremission-Offene-Punkte
- feat(remission-list, shared-filter, ui-input-controls): enhance department filtering and UI improvements
- Merge branch 'develop' into feature/5236-Remission-Abteilungsremission-Offene-Punkte
- Merge branch 'develop' into feature/5236-Remission-Abteilungsremission-Offene-Punkte
- feat(remission-list, remission-data-access): add department capacity display functionality

#5236 #4771 Abteilungsremission
This commit is contained in:
Nino Righi
2025-07-28 19:28:14 +00:00
committed by Lorenz Hilpert
parent baf4a0dfbc
commit 0da9800ca0
21 changed files with 620 additions and 12 deletions

View File

@@ -0,0 +1,207 @@
import {
calculateCapacity,
calculateMaxCapacity,
} from './calc-capacity.helper';
describe('calculateCapacity', () => {
it('should return capacityValue2 when it is smaller than capacityValue3', () => {
// Arrange
const input = { capacityValue2: 5, capacityValue3: 10 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(5);
});
it('should return capacityValue3 when it is smaller than capacityValue2', () => {
// Arrange
const input = { capacityValue2: 15, capacityValue3: 8 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(8);
});
it('should return capacityValue3 when both values are equal', () => {
// Arrange
const input = { capacityValue2: 10, capacityValue3: 10 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should handle zero values correctly', () => {
// Arrange
const input = { capacityValue2: 0, capacityValue3: 5 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(0);
});
it('should handle negative values correctly', () => {
// Arrange
const input = { capacityValue2: -3, capacityValue3: 2 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(-3);
});
});
describe('calculateMaxCapacity', () => {
it('should return capacityValue2 when capacityValue4 is greater than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: 20,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should return capacityValue4 when it is positive and less than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: 8,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(8);
});
it('should return capacityValue2 when capacityValue4 is zero and capacityValue3 is greater than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: 0,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should return capacityValue3 when capacityValue4 is zero and capacityValue3 is positive and less than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 8,
capacityValue4: 0,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(8);
});
it('should return comparer when it is greater than calculated max capacity', () => {
// Arrange
const input = {
capacityValue2: 5,
capacityValue3: 3,
capacityValue4: 2,
comparer: 10,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should handle undefined capacityValue4 with default value 0', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: undefined,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should handle all zero capacity values', () => {
// Arrange
const input = {
capacityValue2: 0,
capacityValue3: 0,
capacityValue4: 0,
comparer: 3,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(3);
});
it('should handle negative capacity values', () => {
// Arrange
const input = {
capacityValue2: -5,
capacityValue3: -3,
capacityValue4: -2,
comparer: 1,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(1);
});
it('should use default values for optional parameters when not provided', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 8,
capacityValue4: undefined,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(8);
});
});

View File

@@ -0,0 +1,56 @@
/**
* Calculates the capacity based on the provided capacity values.
* It returns the minimum of the two capacity values.
* @param {Object} params - The parameters for the calculation
* @param {number} params.capacityValue2 - The second capacity value
* @param {number} params.capacityValue3 - The third capacity value
* @return {number} The calculated capacity
*/
export const calculateCapacity = ({
capacityValue2,
capacityValue3,
}: {
capacityValue2: number;
capacityValue3: number;
}): number => {
return capacityValue3 > capacityValue2 ? capacityValue2 : capacityValue3;
};
/**
* Calculates the maximum capacity based on the provided capacity values.
* It compares the values and returns the maximum capacity that is greater than or equal to the comparer
* or the maximum of the capacity values.
* @param {Object} params - The parameters for the calculation
* @param {number} params.capacityValue2 - The second capacity value
* @param {number} params.capacityValue3 - The third capacity value
* @param {number} params.capacityValue4 - The fourth capacity value (optional)
* @param {number} params.comparer - The value to compare against
* @return {number} The maximum capacity calculated
*/
export const calculateMaxCapacity = ({
capacityValue2 = 0,
capacityValue3 = 0,
capacityValue4 = 0,
comparer,
}: {
capacityValue2: number;
capacityValue3: number;
capacityValue4: number | undefined;
comparer: number;
}): number => {
let maxCapacity = 0;
if (capacityValue4 < capacityValue2) {
if (capacityValue4 > 0) {
maxCapacity = capacityValue4;
} else if (capacityValue3 > capacityValue2) {
maxCapacity = capacityValue2;
} else if (capacityValue3 > 0) {
maxCapacity = capacityValue3;
}
} else {
maxCapacity = capacityValue2;
}
return Math.max(comparer, maxCapacity);
};

View File

@@ -1,3 +1,4 @@
export * from './calc-available-stock.helper';
export * from './calc-stock-to-remit.helper';
export * from './calc-target-stock.helper';
export * from './calc-capacity.helper';

View File

@@ -14,3 +14,4 @@ export * from './stock';
export * from './supplier';
export * from './receipt-return-tuple';
export * from './receipt-return-suggestion-tuple';
export * from './value-tuple-sting-and-integer';

View File

@@ -0,0 +1,10 @@
import { ValueTupleOfStringAndIntegerAndIntegerAndNullableIntegerAndString } from '@generated/swagger/inventory-api';
export interface ValueTupleOfStringAndInteger
extends ValueTupleOfStringAndIntegerAndIntegerAndNullableIntegerAndString {
item1?: string;
item2: number;
item3: number;
item4?: number;
item5?: string;
}

View File

@@ -0,0 +1,9 @@
import { z } from 'zod';
export const FetchRequiredCapacitySchema = z.object({
departments: z.array(z.string()),
supplierId: z.number(),
stockId: z.number(),
});
export type FetchRequiredCapacity = z.infer<typeof FetchRequiredCapacitySchema>;

View File

@@ -8,3 +8,4 @@ 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';

View File

@@ -5,11 +5,14 @@ import {
RemissionListTypeKey,
ReturnItem,
ReturnSuggestion,
ValueTupleOfStringAndInteger,
} from '../models';
import { RemiService } from '@generated/swagger/inventory-api';
import {
FetchQuerySettings,
FetchQuerySettingsSchema,
FetchRequiredCapacity,
FetchRequiredCapacitySchema,
RemissionQueryTokenInput,
RemissionQueryTokenSchema,
} from '../schemas';
@@ -78,6 +81,58 @@ export class RemissionSearchService {
);
}
/**
* Fetches the required capacity for remission based on departments and supplier ID.
* Validates input parameters using FetchRequiredCapacitySchema.
*
* @async
* @param {FetchRequiredCapacity} params - Parameters for fetching required capacity
* @param {string[]} params.departments - List of department names
* @param {number} params.supplierId - ID of the supplier
* @param {number} params.stockId - ID of the stock
* @returns {Promise<ValueTupleOfStringAndInteger[]>} Required capacity data as an array of key-value pairs
* @throws {Error} When the API request fails or returns an error
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const capacity = await service.fetchRequiredCapacity({
* departments: ['Department1', 'Department2'],
* supplierId: 123,
* stockId: 456
* });
*/
async fetchRequiredCapacity(
params: FetchRequiredCapacity,
): Promise<ValueTupleOfStringAndInteger[]> {
this.#logger.debug('Fetching required capacity', () => ({ params }));
const parsed = FetchRequiredCapacitySchema.parse(params);
this.#logger.info('Fetching required capacity from API', () => ({
stockId: parsed.stockId,
departments: parsed.departments,
supplierId: parsed.supplierId,
}));
const req$ = this.#remiService.RemiGetRequiredCapacities({
stockId: parsed.stockId,
payload: {
departments: parsed.departments,
supplierId: parsed.supplierId,
},
});
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch required capacity', error);
throw error;
}
this.#logger.debug('Successfully fetched required capacity');
return (res?.result ?? []) as ValueTupleOfStringAndInteger[];
}
/**
* Fetches query settings for mandatory remission articles.
* Validates input parameters using FetchQuerySettingsSchema.

View File

@@ -0,0 +1,32 @@
<filter-input-menu-button
[filterInput]="filterDepartmentInput()"
[label]="selectedDepartments()"
[commitOnClose]="true"
>
</filter-input-menu-button>
@if (displayCapacityValues()) {
<ui-toolbar class="ui-toolbar-rounded">
<span class="isa-text-body-2-regular"
><span class="isa-text-body-2-bold"
>{{ leistung() }}/{{ maxLeistung() }}</span
>
Leistung</span
>
<span class="isa-text-body-2-regular"
><span class="isa-text-body-2-bold"
>{{ stapel() }}/{{ maxStapel() }}</span
>
Stapel</span
>
<button
class="w-6 h-6 flex items-center justify-center text-isa-accent-blue"
uiTooltip
[title]="'Stapel/Leistungsplätze'"
[content]="''"
[triggerOn]="['click', 'hover']"
>
<ng-icon size="1.5rem" name="isaOtherInfo"></ng-icon>
</button>
</ui-toolbar>
}

View File

@@ -0,0 +1,7 @@
:host {
@apply flex flex-row gap-4 items-center max-h-12;
}
.ui-toolbar-rounded {
@apply rounded-2xl;
}

View File

@@ -0,0 +1,138 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { isaOtherInfo } from '@isa/icons';
import {
calculateCapacity,
calculateMaxCapacity,
} from '@isa/remission/data-access';
import {
FilterInputMenuButtonComponent,
FilterService,
InputType,
} from '@isa/shared/filter';
import { ToolbarComponent } from '@isa/ui/toolbar';
import { TooltipDirective } from '@isa/ui/tooltip';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { createRemissionCapacityResource } from '../resources';
@Component({
selector: 'remi-feature-remission-list-department-elements',
templateUrl: './remission-list-department-elements.component.html',
styleUrl: './remission-list-department-elements.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [provideIcons({ isaOtherInfo })],
imports: [
FilterInputMenuButtonComponent,
ToolbarComponent,
TooltipDirective,
NgIconComponent,
],
})
export class RemissionListDepartmentElementsComponent {
/**
* FilterService instance for managing filter state and queries.
* @private
*/
#filterService = inject(FilterService);
/**
* Filter input for departments, used to filter remission items by department.
*/
filterDepartmentInput = computed(() => {
const inputs = this.#filterService
.inputs()
.filter((input) => input.group === 'filter');
return inputs?.find((input) => input.key === 'abteilungen');
});
/**
* Computed signal for the selected departments from the filter input.
* If the input type is Checkbox and has selected values, it returns a comma-separated string.
* Otherwise, it returns undefined.
*/
selectedDepartments = computed(() => {
const input = this.filterDepartmentInput();
if (input?.type === InputType.Checkbox && input?.selected?.length > 0) {
return input?.selected?.filter((selected) => !!selected).join(', ');
}
return;
});
/**
* Resource signal for fetching remission capacity based on selected departments.
* Updates when the selected departments change.
* @returns Remission capacity resource state.
*/
capacityResource = createRemissionCapacityResource(() => {
return {
departments: this.selectedDepartments()
?.split(',')
.map((d) => d.trim()),
};
});
capacityResourceValue = computed(() => this.capacityResource.value());
displayCapacityValues = computed(() => {
const value = this.capacityResourceValue();
return !!value && value?.length > 0;
});
leistungValues = computed(() => {
const value = this.capacityResourceValue();
return value?.find((cap) => cap.item1 === 'Leistung');
});
stapelValues = computed(() => {
const value = this.capacityResourceValue();
return value?.find((cap) => cap.item1 === 'Stapel');
});
leistung = computed(() => {
const values = this.leistungValues();
return values
? calculateCapacity({
capacityValue2: values.item2,
capacityValue3: values.item3,
})
: 0;
});
maxLeistung = computed(() => {
const values = this.leistungValues();
return values
? calculateMaxCapacity({
capacityValue2: values.item2,
capacityValue3: values.item3,
capacityValue4: values.item4,
comparer: this.leistung(),
})
: 0;
});
stapel = computed(() => {
const values = this.stapelValues();
return values
? calculateCapacity({
capacityValue2: values.item2,
capacityValue3: values.item3,
})
: 0;
});
maxStapel = computed(() => {
const values = this.stapelValues();
return values
? calculateMaxCapacity({
capacityValue2: values.item2,
capacityValue3: values.item3,
capacityValue4: values.item4,
comparer: this.stapel(),
})
: 0;
});
}

View File

@@ -1,6 +1,7 @@
<ui-dropdown
class="remi-feature-remission-list-select__dropdown"
[value]="selectedRemissionListType()"
[label]="selectedRemissionListTypeLabel()"
[appearance]="DropdownAppearance.Grey"
(valueChange)="changeRemissionType($event)"
data-which="remission-list-select-dropdown"

View File

@@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
RemissionListType,
@@ -30,8 +35,6 @@ export class RemissionListSelectComponent {
selectedRemissionListType = injectRemissionListType();
async changeRemissionType(remissionTypeValue: RemissionListType | undefined) {
console.log(remissionTypeValue, remissionListTypeRouteMapping);
if (
!remissionTypeValue ||
remissionTypeValue === RemissionListType.Koerperlos
@@ -45,4 +48,18 @@ export class RemissionListSelectComponent {
},
);
}
selectedRemissionListTypeLabel = computed(() => {
const type = this.selectedRemissionListType();
if (type === RemissionListType.Pflicht) {
return 'Pflicht';
}
if (type === RemissionListType.Abteilung) {
return 'Abteilungen';
}
return;
});
}

View File

@@ -6,7 +6,12 @@
<remi-feature-remission-return-card></remi-feature-remission-return-card>
}
<remi-feature-remission-list-select></remi-feature-remission-list-select>
<div class="flex flex-row gap-4 items-center max-h-12">
<remi-feature-remission-list-select></remi-feature-remission-list-select>
@if (isDepartment()) {
<remi-feature-remission-list-department-elements></remi-feature-remission-list-department-elements>
}
</div>
<filter-controls-panel (triggerSearch)="search($event)"></filter-controls-panel>

View File

@@ -46,6 +46,7 @@ import { RemissionListType } from '@isa/remission/data-access';
import { RemissionReturnCardComponent } from './remission-return-card/remission-return-card.component';
import { logger } from '@isa/core/logging';
import { RemissionProcessedHintComponent } from './remission-processed-hint/remission-processed-hint.component';
import { RemissionListDepartmentElementsComponent } from './remission-list-department-elements/remission-list-department-elements.component';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
@@ -85,6 +86,7 @@ function querySettingsFactory() {
RemissionListItemComponent,
IconButtonComponent,
StatefulButtonComponent,
RemissionListDepartmentElementsComponent,
RemissionProcessedHintComponent,
],
host: {
@@ -138,6 +140,14 @@ export class RemissionListComponent {
*/
selectedRemissionListType = injectRemissionListType();
/**
* Computed signal indicating whether the remission list type is 'Abteilung'.
* @returns True if the selected type is Abteilung, false otherwise.
*/
isDepartment = computed(() => {
return this.selectedRemissionListType() === RemissionListType.Abteilung;
});
/**
* Resource signal for fetching the remission list based on current filters.
* @returns Remission list resource state.
@@ -255,7 +265,7 @@ export class RemissionListComponent {
/**
* Commits the current filter state and triggers a new search.
*
*
* @param trigger - The type of search trigger that initiated this search.
* Used to track user interaction patterns and optimize search behavior.
*/
@@ -306,7 +316,7 @@ export class RemissionListComponent {
* Computed signal that determines if the current search was triggered by user interaction.
* Returns true for user-initiated actions (input, filter changes, sort changes, scanning)
* and false for automatic/system-initiated searches (reload, initial load).
*
*
* @returns True if search was user-initiated, false for system-initiated searches
*/
searchTriggeredByUser = computed(() => {
@@ -336,13 +346,11 @@ export class RemissionListComponent {
if (!this.searchTriggeredByUser()) {
return;
}
const isDepartment =
this.selectedRemissionListType() === RemissionListType.Abteilung;
this.searchItemToRemitDialog({
data: {
searchTerm: this.#filterService.query()?.input['qs'] || '',
isDepartment,
isDepartment: this.isDepartment(),
},
}).closed.subscribe(async (result) => {
if (result) {

View File

@@ -1,3 +1,4 @@
export * from './remission-list.resource';
export * from './remission-instock.resource';
export * from './remission-product-group.resource';
export * from './remission-capacity.resource';

View File

@@ -0,0 +1,53 @@
import { inject, resource } from '@angular/core';
import {
RemissionSearchService,
RemissionStockService,
RemissionSupplierService,
} from '@isa/remission/data-access';
/**
* Resource for fetching remission capacity based on departments.
* This resource will fetch the required capacity for the given departments
* using the RemissionSearchService and RemissionStockService.
*
* @param {Function} params - Function that returns an object with departments
* @returns {Resource} A resource that can be used to fetch remission capacity
*/
export const createRemissionCapacityResource = (
params: () => {
departments?: string[];
},
) => {
const remissionSearchService = inject(RemissionSearchService);
const remissionStockService = inject(RemissionStockService);
const remissionSupplierService = inject(RemissionSupplierService);
return resource({
params,
loader: async ({ abortSignal, params }) => {
if (!params.departments || params.departments.length === 0) {
return [];
}
const assignedStock = await remissionStockService.fetchAssignedStock();
if (!assignedStock || !assignedStock.id) {
throw new Error('No current stock available');
}
const suppliers =
await remissionSupplierService.fetchSuppliers(abortSignal);
const firstSupplier = suppliers[0]; // Es gibt aktuell nur Blank als Supplier
if (!firstSupplier || !firstSupplier.id) {
throw new Error('No Supplier available');
}
return await remissionSearchService.fetchRequiredCapacity({
departments: params.departments,
supplierId: firstSupplier.id,
stockId: assignedStock.id,
});
},
});
};

View File

@@ -11,7 +11,7 @@
#trigger="cdkOverlayOrigin"
>
<span class="filter-input-button__filter-button-label">{{
input.label
label() ?? input.label
}}</span>
<ng-icon
class="filter-input-button__filter-button-icon"

View File

@@ -2,7 +2,7 @@
@apply flex flex-row gap-2 items-center justify-center px-6 h-12 bg-isa-neutral-400 rounded-[3.125rem] border border-solid border-transparent;
.filter-input-button__filter-button-label {
@apply text-isa-neutral-600 isa-text-body-2-bold;
@apply text-isa-neutral-600 isa-text-body-2-bold overflow-hidden text-ellipsis whitespace-nowrap max-w-[9rem];
}
.filter-input-button__filter-button-icon {

View File

@@ -86,6 +86,12 @@ export class FilterInputMenuButtonComponent {
return this.#filter.isDefaultFilterInput(input);
});
/**
* The label for the input menu button.
* If not provided, it defaults to the label of the filter input.
*/
label = input<string | undefined>(undefined);
/**
* Subscribes to the `applied` event to automatically close the menu.
*/

View File

@@ -182,7 +182,7 @@ export class DropdownButtonComponent<T>
return this.label() ?? this.value();
}
return selectedOption.getLabel();
return this.label() ?? selectedOption.getLabel();
});
constructor() {