mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merge branch 'develop' into feature/5087-application-shell
# Conflicts: # libs/ui/layout/src/index.ts
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -80,3 +80,6 @@ CLAUDE.md
|
||||
*.pyc
|
||||
.vite
|
||||
reports/
|
||||
|
||||
# Local iPad dev setup (proxy)
|
||||
/local-dev/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* tslint:disable */
|
||||
import { EntityDTOBaseOfDisplayOrderItemDTOAndIOrderItem } from './entity-dtobase-of-display-order-item-dtoand-iorder-item';
|
||||
import { KeyValueDTOOfStringAndString } from './key-value-dtoof-string-and-string';
|
||||
import { LoyaltyDTO } from './loyalty-dto';
|
||||
import { DisplayOrderDTO } from './display-order-dto';
|
||||
import { PriceDTO } from './price-dto';
|
||||
@@ -9,6 +10,11 @@ import { QuantityUnitType } from './quantity-unit-type';
|
||||
import { DisplayOrderItemSubsetDTO } from './display-order-item-subset-dto';
|
||||
export interface DisplayOrderItemDTO extends EntityDTOBaseOfDisplayOrderItemDTOAndIOrderItem{
|
||||
|
||||
/**
|
||||
* Mögliche Aktionen
|
||||
*/
|
||||
actions?: Array<KeyValueDTOOfStringAndString>;
|
||||
|
||||
/**
|
||||
* Bemerkung des Auftraggebers
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/* tslint:disable */
|
||||
import { EntityDTOBaseOfDisplayOrderItemSubsetDTOAndIOrderItemStatus } from './entity-dtobase-of-display-order-item-subset-dtoand-iorder-item-status';
|
||||
import { KeyValueDTOOfStringAndString } from './key-value-dtoof-string-and-string';
|
||||
import { DateRangeDTO } from './date-range-dto';
|
||||
import { DisplayOrderItemDTO } from './display-order-item-dto';
|
||||
import { OrderItemProcessingStatusValue } from './order-item-processing-status-value';
|
||||
export interface DisplayOrderItemSubsetDTO extends EntityDTOBaseOfDisplayOrderItemSubsetDTOAndIOrderItemStatus{
|
||||
|
||||
/**
|
||||
* Mögliche Aktionen
|
||||
*/
|
||||
actions?: Array<KeyValueDTOOfStringAndString>;
|
||||
|
||||
/**
|
||||
* Abholfachnummer
|
||||
*/
|
||||
@@ -40,6 +46,11 @@ export interface DisplayOrderItemSubsetDTO extends EntityDTOBaseOfDisplayOrderIt
|
||||
*/
|
||||
estimatedShippingDate?: string;
|
||||
|
||||
/**
|
||||
* Zusätzliche Markierungen (z.B. Abo, ...)
|
||||
*/
|
||||
features?: {[key: string]: string};
|
||||
|
||||
/**
|
||||
* Bestellposten
|
||||
*/
|
||||
|
||||
@@ -118,6 +118,11 @@ export interface ReceiptDTO extends EntityDTOBaseOfReceiptDTOAndIReceipt{
|
||||
*/
|
||||
receiptNumber?: string;
|
||||
|
||||
/**
|
||||
* Subtype of the receipt / Beleg-Unterart
|
||||
*/
|
||||
receiptSubType?: string;
|
||||
|
||||
/**
|
||||
* Belegtext
|
||||
*/
|
||||
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
} from '@isa/checkout/data-access';
|
||||
|
||||
/**
|
||||
* Creates a unique key for an item based on EAN, destination, and orderItemType.
|
||||
* Creates a unique key for an item based on EAN, targetBranchId, and orderItemType.
|
||||
* Items are only considered identical if all three match.
|
||||
*/
|
||||
export const getItemKey = (item: ShoppingCartItem): string => {
|
||||
const ean = item.product.ean ?? 'no-ean';
|
||||
const destinationId = item.destination?.data?.id ?? 'no-destination';
|
||||
const targetBranchId =
|
||||
item.destination?.data?.targetBranch?.id ?? 'no-target-branch-id';
|
||||
const orderType = getOrderTypeFeature(item.features) ?? 'no-orderType';
|
||||
return `${ean}|${destinationId}|${orderType}`;
|
||||
return `${ean}|${targetBranchId}|${orderType}`;
|
||||
};
|
||||
|
||||
@@ -73,9 +73,6 @@ export class RewardCatalogComponent {
|
||||
#filterService = inject(FilterService);
|
||||
|
||||
displayStockFilterSwitch = computed(() => {
|
||||
if (this.isCallCenter) {
|
||||
return [];
|
||||
}
|
||||
const stockInput = this.#filterService
|
||||
.inputs()
|
||||
?.filter((input) => input.target === 'filter')
|
||||
|
||||
@@ -49,7 +49,7 @@ export class OrderConfirmationHeaderComponent {
|
||||
if (!orders || orders.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return orders
|
||||
const formattedDates = orders
|
||||
.map((order) => {
|
||||
if (!order.orderDate) {
|
||||
return null;
|
||||
@@ -60,7 +60,8 @@ export class OrderConfirmationHeaderComponent {
|
||||
);
|
||||
return formatted ? `${formatted} Uhr` : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
.filter(Boolean);
|
||||
|
||||
return [...new Set(formattedDates)].join('; ');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ export class RewardShoppingCartItemQuantityControlComponent {
|
||||
if (
|
||||
orderType === OrderTypeFeature.Delivery ||
|
||||
orderType === OrderTypeFeature.DigitalShipping ||
|
||||
orderType === OrderTypeFeature.B2BShipping
|
||||
orderType === OrderTypeFeature.B2BShipping ||
|
||||
orderType === OrderTypeFeature.Pickup
|
||||
) {
|
||||
return 999;
|
||||
}
|
||||
|
||||
@@ -37,11 +37,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (quantityControl.maxQuantity() < 2 && !isDownload()) {
|
||||
<div
|
||||
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
|
||||
>
|
||||
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
|
||||
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
|
||||
</div>
|
||||
@if (!isDownload()) {
|
||||
@if (showLowStockMessage()) {
|
||||
<div
|
||||
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
|
||||
>
|
||||
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
|
||||
<div>{{ inStock() }} Exemplare sofort lieferbar</div>
|
||||
</div>
|
||||
} @else if (quantityControl.maxQuantity() < 2) {
|
||||
<div
|
||||
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
|
||||
>
|
||||
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
|
||||
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +72,20 @@ export class RewardShoppingCartItemComponent {
|
||||
hasOrderTypeFeature(this.item().features, ['Download']),
|
||||
);
|
||||
|
||||
isAbholung = computed(() =>
|
||||
hasOrderTypeFeature(this.item().features, ['Abholung']),
|
||||
);
|
||||
|
||||
inStock = computed(() => this.item().availability?.inStock ?? 0);
|
||||
|
||||
showLowStockMessage = computed(() => {
|
||||
return this.isAbholung() && this.inStock() < 2;
|
||||
});
|
||||
|
||||
async updatePurchaseOption() {
|
||||
const shoppingCartItemId = this.itemId();
|
||||
const shoppingCartId = this.shoppingCartId();
|
||||
const branch = this.item().destination?.data?.targetBranch?.data;
|
||||
|
||||
if (this.isBusy() || !shoppingCartId || !shoppingCartItemId) {
|
||||
return;
|
||||
@@ -90,6 +101,8 @@ export class RewardShoppingCartItemComponent {
|
||||
useRedemptionPoints: true,
|
||||
disabledPurchaseOptions: ['b2b-delivery'],
|
||||
hideDisabledPurchaseOptions: true,
|
||||
pickupBranch: branch,
|
||||
inStoreBranch: branch,
|
||||
});
|
||||
|
||||
await firstValueFrom(ref.afterClosed$);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
:host {
|
||||
@apply text-isa-accent-red isa-text-body-2-bold flex flex-row gap-2 items-center;
|
||||
@apply text-isa-accent-red isa-text-body-2-bold flex flex-col gap-2 items-start;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@if (store.totalLoyaltyPointsNeeded() > store.customerRewardPoints()) {
|
||||
<ng-icon
|
||||
class="w-6 h-6 inline-flex items-center justify-center"
|
||||
size="1.5rem"
|
||||
name="isaOtherInfo"
|
||||
></ng-icon>
|
||||
<span>Lesepunkte reichen nicht für alle Artikel</span>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<ng-icon
|
||||
class="w-6 h-6 inline-flex items-center justify-center"
|
||||
size="1.5rem"
|
||||
name="isaOtherInfo"
|
||||
></ng-icon>
|
||||
<span>Lesepunkte reichen nicht für alle Artikel</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -41,10 +41,7 @@ export class RewardSelectionInputsComponent {
|
||||
|
||||
hasCorrectOrderType = computed(() => {
|
||||
const item = this.rewardSelectionItem().item;
|
||||
return hasOrderTypeFeature(item.features, [
|
||||
OrderTypeFeature.InStore,
|
||||
OrderTypeFeature.Pickup,
|
||||
]);
|
||||
return hasOrderTypeFeature(item.features, [OrderTypeFeature.InStore]);
|
||||
});
|
||||
|
||||
hasStock = computed(() => {
|
||||
|
||||
@@ -27,3 +27,16 @@
|
||||
|
||||
<lib-reward-selection-inputs></lib-reward-selection-inputs>
|
||||
</div>
|
||||
|
||||
@if (showLowStockMessage()) {
|
||||
<div
|
||||
class="flex flex-row gap-2 items-center text-isa-accent-red isa-text-body-2-bold"
|
||||
>
|
||||
<ng-icon
|
||||
class="w-6 h-6 inline-flex items-center justify-center"
|
||||
size="1.5rem"
|
||||
name="isaOtherInfo"
|
||||
></ng-icon>
|
||||
<span>{{ inStock() }} Exemplare sofort lieferbar</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { ProductImageDirective } from '@isa/shared/product-image';
|
||||
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
|
||||
import { RewardSelectionInputsComponent } from './reward-selection-inputs/reward-selection-inputs.component';
|
||||
import { RewardSelectionItem } from '@isa/checkout/data-access';
|
||||
import {
|
||||
hasOrderTypeFeature,
|
||||
RewardSelectionItem,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { isaOtherInfo } from '@isa/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-reward-selection-item',
|
||||
@@ -13,8 +23,24 @@ import { RewardSelectionItem } from '@isa/checkout/data-access';
|
||||
ProductImageDirective,
|
||||
ProductRouterLinkDirective,
|
||||
RewardSelectionInputsComponent,
|
||||
NgIcon,
|
||||
],
|
||||
providers: [provideIcons({ isaOtherInfo })],
|
||||
})
|
||||
export class RewardSelectionItemComponent {
|
||||
rewardSelectionItem = input.required<RewardSelectionItem>();
|
||||
|
||||
inStock = computed(
|
||||
() => this.rewardSelectionItem().item?.availability?.inStock ?? 0,
|
||||
);
|
||||
|
||||
isAbholung = computed(() =>
|
||||
hasOrderTypeFeature(this.rewardSelectionItem()?.item?.features, [
|
||||
'Abholung',
|
||||
]),
|
||||
);
|
||||
|
||||
showLowStockMessage = computed(() => {
|
||||
return this.isAbholung() && this.inStock() < 2;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,8 +34,15 @@ export const catchResponseArgsErrorPipe = <T>(): OperatorFunction<T, T> =>
|
||||
return throwError(() => err);
|
||||
}),
|
||||
mergeMap((response) => {
|
||||
if (isResponseArgs(response) && response.error === true) {
|
||||
return throwError(() => new ResponseArgsError(response));
|
||||
if (isResponseArgs(response)) {
|
||||
// Treat as error if error flag is true OR if invalidProperties has entries
|
||||
const hasInvalidProps =
|
||||
response.invalidProperties &&
|
||||
Object.keys(response.invalidProperties).length > 0;
|
||||
|
||||
if (response.error === true || hasInvalidProps) {
|
||||
return throwError(() => new ResponseArgsError(response));
|
||||
}
|
||||
}
|
||||
|
||||
return [response];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject, resource, signal, computed } from '@angular/core';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { CustomerBonRedemptionFacade } from '../facades/customer-bon-redemption.facade';
|
||||
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
|
||||
import { ResponseArgsError } from '@isa/common/data-access';
|
||||
|
||||
/**
|
||||
* Resource for checking/validating Bon numbers.
|
||||
@@ -47,8 +48,24 @@ export class CustomerBonCheckResource {
|
||||
this.#logger.debug('Bon checked', () => ({
|
||||
bonNr,
|
||||
found: !!response?.result,
|
||||
hasInvalidProperties:
|
||||
!!response?.invalidProperties &&
|
||||
Object.keys(response.invalidProperties).length > 0,
|
||||
}));
|
||||
|
||||
// Check for invalidProperties even when error is false
|
||||
// Backend may return { error: false, invalidProperties: {...} } for validation issues
|
||||
if (
|
||||
response?.invalidProperties &&
|
||||
Object.keys(response.invalidProperties).length > 0
|
||||
) {
|
||||
this.#logger.warn('Bon check has invalid properties', () => ({
|
||||
bonNr,
|
||||
invalidProperties: response.invalidProperties,
|
||||
}));
|
||||
throw new ResponseArgsError(response);
|
||||
}
|
||||
|
||||
return response?.result;
|
||||
},
|
||||
defaultValue: undefined,
|
||||
|
||||
@@ -224,15 +224,10 @@ export class CrmSearchService {
|
||||
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await firstValueFrom(req$);
|
||||
this.#logger.debug('Successfully fetched current booking partner store');
|
||||
const res = await firstValueFrom(req$);
|
||||
this.#logger.debug('Successfully fetched current booking partner store');
|
||||
|
||||
return res?.result;
|
||||
} catch (error) {
|
||||
this.#logger.error('Error fetching current booking partner store', error);
|
||||
return undefined;
|
||||
}
|
||||
return res?.result;
|
||||
}
|
||||
|
||||
async addBooking(
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
aria-label="Bon Details"
|
||||
>
|
||||
<div class="flex justify-between items-center py-1">
|
||||
<span class="isa-text-body-2-regular text-isa-neutral-600">Bon Datum</span>
|
||||
<span class="isa-text-body-2-regular text-isa-neutral-600"
|
||||
>Bon Datum</span
|
||||
>
|
||||
<span
|
||||
class="isa-text-body-2-bold text-isa-black"
|
||||
data-what="bon-date"
|
||||
[attr.data-which]="bon.bonNumber"
|
||||
>
|
||||
{{ bon.date }}
|
||||
{{ bon.date | date: 'dd.MM.yyyy' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -114,12 +114,7 @@ export class CrmFeatureCustomerBonRedemptionComponent {
|
||||
}
|
||||
// Handle API errors
|
||||
else if (error) {
|
||||
let errorMsg = 'Bon-Validierung fehlgeschlagen';
|
||||
if (error instanceof ResponseArgsError) {
|
||||
errorMsg = error.message || errorMsg;
|
||||
} else if (error instanceof Error) {
|
||||
errorMsg = error.message;
|
||||
}
|
||||
const errorMsg = this.#extractErrorMessage(error);
|
||||
this.store.setError(errorMsg);
|
||||
}
|
||||
});
|
||||
@@ -224,4 +219,23 @@ export class CrmFeatureCustomerBonRedemptionComponent {
|
||||
this.store.reset();
|
||||
this.#bonCheckResource.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from various error types.
|
||||
* ResponseArgsError already formats invalidProperties into a readable message.
|
||||
*/
|
||||
#extractErrorMessage(error: unknown): string {
|
||||
const defaultMsg = 'Bon-Validierung fehlgeschlagen';
|
||||
const actualError = (error as { cause?: unknown })?.cause ?? error;
|
||||
|
||||
if (actualError instanceof ResponseArgsError) {
|
||||
return actualError.message || defaultMsg;
|
||||
}
|
||||
|
||||
if (actualError instanceof Error) {
|
||||
return actualError.message || defaultMsg;
|
||||
}
|
||||
|
||||
return defaultMsg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
|
||||
});
|
||||
|
||||
describe('formattedPoints computed signal', () => {
|
||||
it('should display points from primary card', () => {
|
||||
it('should display points from first card', () => {
|
||||
fixture.componentRef.setInput('cards', mockCards);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
|
||||
expect(component.formattedPoints()).toBe('123.456');
|
||||
});
|
||||
|
||||
it('should display 0 when no primary card exists', () => {
|
||||
it('should display points from first card regardless of isPrimary flag', () => {
|
||||
const cardsWithoutPrimary: BonusCardInfo[] = [
|
||||
{
|
||||
code: 'CARD-1',
|
||||
@@ -93,7 +93,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
|
||||
fixture.componentRef.setInput('cards', cardsWithoutPrimary);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.formattedPoints()).toBe('0');
|
||||
expect(component.formattedPoints()).toBe('1.500');
|
||||
});
|
||||
|
||||
it('should display 0 when cards array is empty', () => {
|
||||
@@ -122,14 +122,14 @@ describe('CustomerCardPointsSummaryComponent', () => {
|
||||
expect(component.formattedPoints()).toBe('0');
|
||||
});
|
||||
|
||||
it('should only use primary card points, not sum of all cards', () => {
|
||||
it('should only use first card points, not sum of all cards', () => {
|
||||
const multipleCards: BonusCardInfo[] = [
|
||||
{
|
||||
code: 'CARD-1',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
isActive: true,
|
||||
isPrimary: true,
|
||||
isPrimary: false,
|
||||
totalPoints: 1000,
|
||||
cardNumber: '1234-5678-9012-3456',
|
||||
} as BonusCardInfo,
|
||||
@@ -138,7 +138,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
isActive: true,
|
||||
isPrimary: false,
|
||||
isPrimary: true,
|
||||
totalPoints: 500,
|
||||
cardNumber: '9876-5432-1098-7654',
|
||||
} as BonusCardInfo,
|
||||
@@ -147,7 +147,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
|
||||
fixture.componentRef.setInput('cards', multipleCards);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Should be 1000, not 1500
|
||||
// Should be 1000 (first card), not 500 (primary) or 1500 (sum)
|
||||
expect(component.formattedPoints()).toBe('1.000');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,12 +36,11 @@ export class CustomerCardPointsSummaryComponent {
|
||||
readonly navigateToPraemienshop = output<void>();
|
||||
|
||||
/**
|
||||
* Total points from primary card, formatted with thousands separator.
|
||||
* Total points from first card, formatted with thousands separator.
|
||||
*/
|
||||
readonly formattedPoints = computed(() => {
|
||||
const cards = this.cards();
|
||||
const primaryCard = cards.find((c) => c.isPrimary);
|
||||
const points = primaryCard?.totalPoints ?? 0;
|
||||
const points = cards?.[0]?.totalPoints ?? 0;
|
||||
|
||||
// Format with German thousands separator (dot)
|
||||
return points.toLocaleString('de-DE');
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from './get-receipt-item-quantity.helper';
|
||||
export * from './get-return-info.helper';
|
||||
export * from './get-return-process-questions.helper';
|
||||
export * from './get-tolino-questions.helper';
|
||||
export * from './is-task-type.helper';
|
||||
export * from './receipt-item-has-category.helper';
|
||||
export * from './return-details-mapping.helper';
|
||||
export * from './return-receipt-values-mapping.helper';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { TaskActionTypes } from '../../models';
|
||||
import { isTaskType } from './is-task-type.helper';
|
||||
|
||||
describe('isTaskType', () => {
|
||||
describe('OK type matching', () => {
|
||||
it('should return true when comparing OK with OK', () => {
|
||||
expect(isTaskType('OK', TaskActionTypes.OK)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when comparing RETOURE_OK with OK', () => {
|
||||
expect(isTaskType('RETOURE_OK', TaskActionTypes.OK)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when comparing OK with RETOURE_OK', () => {
|
||||
expect(isTaskType('OK', TaskActionTypes.RETOURE_OK)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when comparing RETOURE_OK with RETOURE_OK', () => {
|
||||
expect(isTaskType('RETOURE_OK', TaskActionTypes.RETOURE_OK)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NOK type matching', () => {
|
||||
it('should return true when comparing NOK with NOK', () => {
|
||||
expect(isTaskType('NOK', TaskActionTypes.NOK)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when comparing RETOURE_NOK with NOK', () => {
|
||||
expect(isTaskType('RETOURE_NOK', TaskActionTypes.NOK)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when comparing NOK with RETOURE_NOK', () => {
|
||||
expect(isTaskType('NOK', TaskActionTypes.RETOURE_NOK)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when comparing RETOURE_NOK with RETOURE_NOK', () => {
|
||||
expect(isTaskType('RETOURE_NOK', TaskActionTypes.RETOURE_NOK)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UNKNOWN type matching', () => {
|
||||
it('should return true when comparing UNKNOWN with UNKNOWN', () => {
|
||||
expect(isTaskType('UNKNOWN', TaskActionTypes.UNKNOWN)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when comparing RETOURE_UNKNOWN with UNKNOWN', () => {
|
||||
expect(isTaskType('RETOURE_UNKNOWN', TaskActionTypes.UNKNOWN)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when comparing UNKNOWN with RETOURE_UNKNOWN', () => {
|
||||
expect(isTaskType('UNKNOWN', TaskActionTypes.RETOURE_UNKNOWN)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when comparing RETOURE_UNKNOWN with RETOURE_UNKNOWN', () => {
|
||||
expect(
|
||||
isTaskType('RETOURE_UNKNOWN', TaskActionTypes.RETOURE_UNKNOWN),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-matching types', () => {
|
||||
it('should return false when comparing OK with NOK', () => {
|
||||
expect(isTaskType('OK', TaskActionTypes.NOK)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when comparing RETOURE_OK with UNKNOWN', () => {
|
||||
expect(isTaskType('RETOURE_OK', TaskActionTypes.UNKNOWN)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when comparing NOK with OK', () => {
|
||||
expect(isTaskType('NOK', TaskActionTypes.OK)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('falsy values', () => {
|
||||
it('should return false when value is undefined', () => {
|
||||
expect(isTaskType(undefined, TaskActionTypes.OK)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when value is null', () => {
|
||||
expect(isTaskType(null, TaskActionTypes.OK)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when value is empty string', () => {
|
||||
expect(isTaskType('', TaskActionTypes.OK)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { TaskActionTypeType } from '../../models';
|
||||
|
||||
/**
|
||||
* Checks if a task action type value matches a given type, including its RETOURE_ variant.
|
||||
*
|
||||
* This helper normalizes both values by stripping the 'RETOURE_' prefix before comparison,
|
||||
* allowing flexible matching between base types and their return variants.
|
||||
*
|
||||
* @param value - The task action type value to check (can be a base type or RETOURE_ variant)
|
||||
* @param type - The task action type to compare against (can be a base type or RETOURE_ variant)
|
||||
* @returns `true` if the normalized values match, `false` otherwise
|
||||
*
|
||||
* @example
|
||||
* // All of these return true:
|
||||
* isTaskType('OK', TaskActionTypes.OK) // 'OK' === 'OK'
|
||||
* isTaskType('RETOURE_OK', TaskActionTypes.OK) // 'OK' === 'OK'
|
||||
* isTaskType('OK', TaskActionTypes.RETOURE_OK) // 'OK' === 'OK'
|
||||
* isTaskType('RETOURE_OK', TaskActionTypes.RETOURE_OK) // 'OK' === 'OK'
|
||||
*
|
||||
* @example
|
||||
* // Returns false:
|
||||
* isTaskType('OK', TaskActionTypes.NOK) // 'OK' !== 'NOK'
|
||||
* isTaskType('RETOURE_OK', TaskActionTypes.UNKNOWN) // 'OK' !== 'UNKNOWN'
|
||||
* isTaskType(undefined, TaskActionTypes.OK) // value is falsy
|
||||
*/
|
||||
export const isTaskType = (
|
||||
value: TaskActionTypeType | string | undefined | null,
|
||||
type: TaskActionTypeType,
|
||||
): boolean => {
|
||||
if (!value) return false;
|
||||
const normalizedValue = value.replace('RETOURE_', '');
|
||||
const normalizedType = type.replace('RETOURE_', '');
|
||||
return normalizedValue === normalizedType;
|
||||
};
|
||||
@@ -2,8 +2,11 @@ import { KeyValueDTOOfStringAndString } from '@generated/swagger/oms-api';
|
||||
|
||||
export const TaskActionTypes = {
|
||||
OK: 'OK',
|
||||
RETOURE_OK: 'RETOURE_OK',
|
||||
NOK: 'NOK',
|
||||
RETOURE_NOK: 'RETOURE_NOK',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
RETOURE_UNKNOWN: 'RETOURE_UNKNOWN',
|
||||
} as const;
|
||||
|
||||
export type TaskActionTypeType =
|
||||
@@ -13,6 +16,6 @@ export interface TaskActionType {
|
||||
type: TaskActionTypeType;
|
||||
taskId: number;
|
||||
receiptItemId?: number;
|
||||
updateTo?: Exclude<TaskActionTypeType, 'UNKNOWN'>;
|
||||
updateTo?: Exclude<TaskActionTypeType, 'UNKNOWN' | 'RETOURE_UNKNOWN'>;
|
||||
actions?: Array<KeyValueDTOOfStringAndString>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { map, Observable, throwError } from 'rxjs';
|
||||
import { ReceiptItemTaskListItem, TaskActionTypeType } from '../models';
|
||||
import {
|
||||
ReceiptItemTaskListItem,
|
||||
TaskActionTypes,
|
||||
TaskActionTypeType,
|
||||
} from '../models';
|
||||
import { isTaskType } from '../helpers';
|
||||
import { QueryTokenInput, QueryTokenSchema } from '../schemas';
|
||||
import { ZodError } from 'zod';
|
||||
import { ReturnParseQueryTokenError } from '../errors';
|
||||
@@ -98,7 +103,7 @@ export class ReturnTaskListService {
|
||||
* @throws Error when the update operation fails or returns an error
|
||||
*/
|
||||
updateTaskType(updateTask: {
|
||||
type: Exclude<TaskActionTypeType, 'UNKNOWN'>;
|
||||
type: Exclude<TaskActionTypeType, 'UNKNOWN' | 'RETOURE_UNKNOWN'>;
|
||||
taskId: number;
|
||||
}) {
|
||||
try {
|
||||
@@ -127,18 +132,18 @@ export class ReturnTaskListService {
|
||||
* @private
|
||||
*/
|
||||
private _updateTaskRequestHelper(updateTask: {
|
||||
type: Exclude<TaskActionTypeType, 'UNKNOWN'>;
|
||||
type: Exclude<TaskActionTypeType, 'UNKNOWN' | 'RETOURE_UNKNOWN'>;
|
||||
taskId: number;
|
||||
}): Observable<ResponseArgsOfReceiptItemTaskListItemDTO> {
|
||||
if (!updateTask?.taskId) {
|
||||
return throwError(() => new Error('Task ID missing'));
|
||||
}
|
||||
|
||||
if (updateTask.type === 'OK') {
|
||||
if (isTaskType(updateTask.type, TaskActionTypes.OK)) {
|
||||
return this.#receiptService.ReceiptSetReceiptItemTaskToOK(
|
||||
updateTask.taskId,
|
||||
);
|
||||
} else if (updateTask.type === 'NOK') {
|
||||
} else if (isTaskType(updateTask.type, TaskActionTypes.NOK)) {
|
||||
return this.#receiptService.ReceiptSetReceiptItemTaskToNOK(
|
||||
updateTask.taskId,
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@let taskItem = item();
|
||||
@let taskActionType = type();
|
||||
|
||||
@if (taskActionType === 'UNKNOWN') {
|
||||
@if (isTaskType(taskActionType, TaskActionTypes.UNKNOWN)) {
|
||||
<div
|
||||
data-what="task-list"
|
||||
data-which="processing-comment"
|
||||
@@ -51,14 +51,21 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (taskActionType === 'UNKNOWN' && !taskItem?.completed) {
|
||||
@if (
|
||||
isTaskType(taskActionType, TaskActionTypes.UNKNOWN) && !taskItem?.completed
|
||||
) {
|
||||
<div class="task-unknown-actions">
|
||||
<button
|
||||
class="flex items-center"
|
||||
type="button"
|
||||
uiButton
|
||||
color="secondary"
|
||||
(click)="onActionClick({ type: taskActionType, updateTo: 'OK' })"
|
||||
(click)="
|
||||
onActionClick({
|
||||
type: taskActionType,
|
||||
updateTo: TaskActionTypes.RETOURE_OK,
|
||||
})
|
||||
"
|
||||
data-what="button"
|
||||
data-which="resellable"
|
||||
>
|
||||
@@ -69,7 +76,12 @@
|
||||
type="button"
|
||||
uiButton
|
||||
color="secondary"
|
||||
(click)="onActionClick({ type: taskActionType, updateTo: 'NOK' })"
|
||||
(click)="
|
||||
onActionClick({
|
||||
type: taskActionType,
|
||||
updateTo: TaskActionTypes.RETOURE_NOK,
|
||||
})
|
||||
"
|
||||
data-what="button"
|
||||
data-which="damaged"
|
||||
>
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
} from '@angular/core';
|
||||
import { isaActionCheck, isaActionPrinter } from '@isa/icons';
|
||||
import {
|
||||
isTaskType,
|
||||
Product,
|
||||
ReceiptItemTaskListItem,
|
||||
TaskActionType,
|
||||
TaskActionTypes,
|
||||
TaskActionTypeType,
|
||||
} from '@isa/oms/data-access';
|
||||
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
|
||||
@@ -37,6 +39,8 @@ import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class ReturnTaskListItemComponent {
|
||||
readonly TaskActionTypes = TaskActionTypes;
|
||||
readonly isTaskType = isTaskType;
|
||||
appearance = input<'main' | 'review'>('main');
|
||||
item = input.required<ReceiptItemTaskListItem>();
|
||||
action = output<TaskActionType>();
|
||||
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
} from '@angular/core';
|
||||
import { ReturnTaskListItemComponent } from './return-task-list-item/return-task-list-item.component';
|
||||
import {
|
||||
isTaskType,
|
||||
PrintTolinoReturnReceiptService,
|
||||
QueryTokenInput,
|
||||
ReceiptItemTaskListItem,
|
||||
ReturnTaskListService,
|
||||
ReturnTaskListStore,
|
||||
TaskActionType,
|
||||
TaskActionTypes,
|
||||
TaskActionTypeType,
|
||||
} from '@isa/oms/data-access';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
@@ -86,7 +87,9 @@ export class ReturnTaskListComponent {
|
||||
const appearance = this.appearance();
|
||||
if (processId) {
|
||||
const filter: Record<string, unknown> =
|
||||
appearance === 'review' ? { eob: true } : { completed: false };
|
||||
appearance === 'review'
|
||||
? { eob: true, tasktype: '!retoure_loyalty' }
|
||||
: { completed: false, tasktype: '!retoure_loyalty' };
|
||||
const queryToken: QueryTokenInput = {
|
||||
filter,
|
||||
};
|
||||
@@ -113,7 +116,7 @@ export class ReturnTaskListComponent {
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === 'UNKNOWN' && !!action.updateTo) {
|
||||
if (isTaskType(action.type, TaskActionTypes.UNKNOWN) && !!action.updateTo) {
|
||||
return await this.updateTask({
|
||||
taskId: action.taskId,
|
||||
updateTo: action.updateTo,
|
||||
@@ -149,7 +152,7 @@ export class ReturnTaskListComponent {
|
||||
updateTo,
|
||||
}: {
|
||||
taskId: number;
|
||||
updateTo: Exclude<TaskActionTypeType, 'UNKNOWN'>;
|
||||
updateTo: Exclude<TaskActionTypeType, 'UNKNOWN' | 'RETOURE_UNKNOWN'>;
|
||||
}) {
|
||||
try {
|
||||
const processId = this.processId();
|
||||
|
||||
@@ -63,6 +63,18 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
uiButton
|
||||
color="tertiary"
|
||||
size="large"
|
||||
(click)="abortRemission()"
|
||||
class="fixed right-[15rem] bottom-6"
|
||||
>
|
||||
Warenbegleitschein abbrechen
|
||||
</button>
|
||||
|
||||
@if (!returnLoading() && !returnData()?.completed) {
|
||||
<lib-remission-return-receipt-complete
|
||||
[returnId]="returnId()"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getPackageNumbersFromReturn,
|
||||
getReceiptItemsFromReturn,
|
||||
getReceiptNumberFromReturn,
|
||||
RemissionStore,
|
||||
} from '@isa/remission/data-access';
|
||||
import { EmptyStateComponent } from '@isa/ui/empty-state';
|
||||
import { EMPTY_WBS_DESCRIPTION, EMPTY_WBS_TITLE } from './constants';
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
RemissionReturnReceiptActionsComponent,
|
||||
RemissionReturnReceiptCompleteComponent,
|
||||
} from '@isa/remission/shared/return-receipt-actions';
|
||||
import { Router } from '@angular/router';
|
||||
import { injectTabId } from '@isa/core/tabs';
|
||||
|
||||
@Component({
|
||||
selector: 'remi-remission-return-receipt-details',
|
||||
@@ -53,6 +56,15 @@ export class RemissionReturnReceiptDetailsComponent {
|
||||
/** Angular Location service for navigation */
|
||||
location = inject(Location);
|
||||
|
||||
/** Remission store for managing remission state */
|
||||
#store = inject(RemissionStore);
|
||||
|
||||
/** Angular Router for navigation */
|
||||
#router = inject(Router);
|
||||
|
||||
/** Injects the current activated tab ID as a signal. */
|
||||
#tabId = injectTabId();
|
||||
|
||||
/**
|
||||
* Required input for the return ID.
|
||||
* Automatically coerced to a number from string input.
|
||||
@@ -111,4 +123,9 @@ export class RemissionReturnReceiptDetailsComponent {
|
||||
const returnData = this.returnData();
|
||||
return getPackageNumbersFromReturn(returnData!) !== '';
|
||||
});
|
||||
|
||||
async abortRemission() {
|
||||
this.#store.clearState();
|
||||
await this.#router.navigate(['/', this.#tabId(), 'remission']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
></filter-search-bar-input>
|
||||
}
|
||||
|
||||
<div class="flex flex-row gap-4 items-center">
|
||||
<div class="flex flex-row gap-4 items-center flex-wrap justify-end">
|
||||
<ng-content></ng-content>
|
||||
|
||||
@for (switchFilter of switchFilters(); track switchFilter.filter.key) {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.filter-checkbox-input {
|
||||
@apply inline-block w-full p-6 text-isa-neutral-900;
|
||||
@apply inline-block w-full p-6 text-isa-neutral-900 overflow-scroll max-h-96;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="trigger"
|
||||
[cdkConnectedOverlayOpen]="open()"
|
||||
[cdkConnectedOverlayPositions]="overlayPositions"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
|
||||
[cdkConnectedOverlayOffsetX]="-10"
|
||||
[cdkConnectedOverlayOffsetY]="18"
|
||||
[cdkConnectedOverlayFlexibleDimensions]="true"
|
||||
[cdkConnectedOverlayGrowAfterOpen]="true"
|
||||
[cdkConnectedOverlayPush]="true"
|
||||
cdkConnectedOverlayWidth="18.375rem"
|
||||
(backdropClick)="toggle()"
|
||||
>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Overlay, OverlayModule } from '@angular/cdk/overlay';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import { DROPDOWN_OVERLAY_POSITIONS } from '@isa/ui/layout';
|
||||
import { FilterMenuComponent } from './filter-menu.component';
|
||||
import { FilterService } from '../../core';
|
||||
|
||||
@@ -30,6 +30,9 @@ export class FilterMenuButtonComponent {
|
||||
|
||||
selectedFilters = this.#filter.selectedFilterCount;
|
||||
|
||||
/** Standard overlay positions for the filter menu panel */
|
||||
readonly overlayPositions = DROPDOWN_OVERLAY_POSITIONS;
|
||||
|
||||
/**
|
||||
* Tracks the open state of the filter menu.
|
||||
*/
|
||||
|
||||
@@ -45,10 +45,7 @@
|
||||
<span> {{ input!.label }} </span>
|
||||
</button>
|
||||
|
||||
<filter-input-renderer
|
||||
class="overflow-scroll"
|
||||
[filterInput]="input!"
|
||||
></filter-input-renderer>
|
||||
<filter-input-renderer [filterInput]="input!"></filter-input-renderer>
|
||||
}
|
||||
|
||||
<filter-actions
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
@apply inline-flex flex-col;
|
||||
@apply bg-isa-white;
|
||||
@apply rounded-[1.25rem];
|
||||
@apply min-w-[14.3125rem] max-w-[18.375rem] max-h-[33.5rem];
|
||||
@apply min-w-[14.3125rem] max-w-[18.375rem];
|
||||
@apply shadow-overlay;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<filter-input-renderer
|
||||
class="overflow-scroll"
|
||||
[filterInput]="filterInput()"
|
||||
></filter-input-renderer>
|
||||
<filter-input-renderer [filterInput]="filterInput()"></filter-input-renderer>
|
||||
<filter-actions
|
||||
[inputKey]="filterInput().key"
|
||||
[canApply]="canApply()"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
:host {
|
||||
@apply inline-flex flex-col;
|
||||
@apply shadow-overlay bg-isa-white rounded-[1.25rem] max-h-[32.3rem];
|
||||
@apply shadow-overlay bg-isa-white rounded-[1.25rem];
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
</div>
|
||||
@if (orderBy.currentDir) {
|
||||
<ng-icon [name]="orderBy.currentDir" size="1.25rem"></ng-icon>
|
||||
} @else {
|
||||
<div class="w-5 h-5"></div>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
:host {
|
||||
@apply inline-flex flex-col;
|
||||
}
|
||||
|
||||
.ui-toolbar {
|
||||
@apply gap-0;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
[cdkConnectedOverlayDisableClose]="false"
|
||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
|
||||
[cdkConnectedOverlayLockPosition]="true"
|
||||
[cdkConnectedOverlayFlexibleDimensions]="true"
|
||||
[cdkConnectedOverlayGrowAfterOpen]="true"
|
||||
[cdkConnectedOverlayPush]="true"
|
||||
[cdkConnectedOverlayScrollStrategy]="blockScrollStrategy"
|
||||
(backdropClick)="close()"
|
||||
(detach)="isOpen.set(false)"
|
||||
|
||||
@@ -13,9 +13,7 @@ import {
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
|
||||
@@ -23,14 +21,15 @@ import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
|
||||
import {
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
ConnectedPosition,
|
||||
ScrollStrategyOptions,
|
||||
} from '@angular/cdk/overlay';
|
||||
import { DropdownAppearance } from './dropdown.types';
|
||||
import { DropdownService } from './dropdown.service';
|
||||
import { CloseOnScrollDirective } from '@isa/ui/layout';
|
||||
import {
|
||||
CloseOnScrollDirective,
|
||||
DROPDOWN_OVERLAY_POSITIONS,
|
||||
} from '@isa/ui/layout';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
import { DropdownOptionComponent } from './dropdown-option.component';
|
||||
import { DropdownFilterComponent } from './dropdown-filter.component';
|
||||
import { DROPDOWN_HOST, DropdownHost } from './dropdown-host';
|
||||
@@ -99,80 +98,8 @@ export class DropdownButtonComponent<T>
|
||||
return this.#scrollStrategy.block();
|
||||
}
|
||||
|
||||
/** Offset in pixels between the trigger and the overlay panel */
|
||||
readonly #overlayOffset = 12;
|
||||
|
||||
/**
|
||||
* Position priority for the overlay panel.
|
||||
* Order: bottom-left, bottom-right, top-left, top-right,
|
||||
* right-top, right-bottom, left-top, left-bottom
|
||||
*/
|
||||
readonly overlayPositions: ConnectedPosition[] = [
|
||||
// Bottom left
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetY: this.#overlayOffset,
|
||||
},
|
||||
// Bottom right
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
offsetY: this.#overlayOffset,
|
||||
},
|
||||
// Top left
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -this.#overlayOffset,
|
||||
},
|
||||
// Top right
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -this.#overlayOffset,
|
||||
},
|
||||
// Right top
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetX: this.#overlayOffset,
|
||||
},
|
||||
// Right bottom
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
offsetX: this.#overlayOffset,
|
||||
},
|
||||
// Left top
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
offsetX: -this.#overlayOffset,
|
||||
},
|
||||
// Left bottom
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
offsetX: -this.#overlayOffset,
|
||||
},
|
||||
];
|
||||
/** Standard overlay positions for the dropdown panel */
|
||||
readonly overlayPositions = DROPDOWN_OVERLAY_POSITIONS;
|
||||
|
||||
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './lib/breakpoint.directive';
|
||||
export * from './lib/breakpoint';
|
||||
export * from './lib/close-on-scroll.directive';
|
||||
export * from './lib/in-viewport.directive';
|
||||
export * from './lib/element-size-observer.directive';
|
||||
export * from './lib/breakpoint.directive';
|
||||
export * from './lib/breakpoint';
|
||||
export * from './lib/close-on-scroll.directive';
|
||||
export * from './lib/in-viewport.directive';
|
||||
export * from './lib/element-size-observer.directive';
|
||||
export * from './lib/overlay-positions';
|
||||
|
||||
@@ -84,8 +84,11 @@ export class CloseOnScrollDirective implements OnDestroy {
|
||||
}
|
||||
this.#isActive = true;
|
||||
|
||||
// Delay listener registration to next frame to skip any stale scroll events
|
||||
this.#pendingActivation = requestAnimationFrame(() => {
|
||||
// Delay listener registration to skip scroll events caused by:
|
||||
// 1. Stale scroll events from before activation
|
||||
// 2. iOS Safari's automatic "scroll into view" when focusing elements
|
||||
// Using setTimeout with 100ms to ensure iOS scroll-into-view completes
|
||||
this.#pendingActivation = window.setTimeout(() => {
|
||||
this.#scrollListener = (event: Event) => {
|
||||
const excludeElement = this.closeOnScrollExclude();
|
||||
if (excludeElement?.contains(event.target as HTMLElement)) {
|
||||
@@ -101,12 +104,12 @@ export class CloseOnScrollDirective implements OnDestroy {
|
||||
{ capture: true, passive: true },
|
||||
);
|
||||
this.#logger.debug('Activated scroll listener');
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
#deactivate(): void {
|
||||
if (this.#pendingActivation) {
|
||||
cancelAnimationFrame(this.#pendingActivation);
|
||||
clearTimeout(this.#pendingActivation);
|
||||
this.#pendingActivation = undefined;
|
||||
}
|
||||
|
||||
|
||||
100
libs/ui/layout/src/lib/overlay-positions.ts
Normal file
100
libs/ui/layout/src/lib/overlay-positions.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ConnectedPosition } from '@angular/cdk/overlay';
|
||||
|
||||
/** Default offset in pixels between the trigger and the overlay panel */
|
||||
export const OVERLAY_OFFSET = 12;
|
||||
|
||||
/**
|
||||
* Creates standard dropdown overlay positions with configurable offset.
|
||||
*
|
||||
* Position priority for overlay panels:
|
||||
* CDK tries positions in order and picks the first one that fits in the viewport.
|
||||
*
|
||||
* With `flexibleDimensions=true` and `push=true`, the overlay will:
|
||||
* 1. Try positions in order
|
||||
* 2. Constrain size to fit in viewport (flexibleDimensions)
|
||||
* 3. Push into viewport if needed (push)
|
||||
*
|
||||
* Priority order (most to least preferred):
|
||||
* 1. Below the trigger (default, most natural for dropdowns)
|
||||
* 2. Above the trigger (when no space below)
|
||||
* 3. To the right (when no vertical space)
|
||||
* 4. To the left (last resort)
|
||||
*
|
||||
* @param offset - Offset in pixels between trigger and overlay (default: 12)
|
||||
* @returns Array of ConnectedPosition configurations
|
||||
*/
|
||||
export const createOverlayPositions = (
|
||||
offset: number = OVERLAY_OFFSET,
|
||||
): ConnectedPosition[] => [
|
||||
// Priority 1: Below trigger, left-aligned (default/preferred)
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetY: offset,
|
||||
},
|
||||
// Priority 2: Below trigger, right-aligned
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
offsetY: offset,
|
||||
},
|
||||
// Priority 3: Above trigger, left-aligned
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -offset,
|
||||
},
|
||||
// Priority 4: Above trigger, right-aligned
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -offset,
|
||||
},
|
||||
// Priority 5: Right of trigger, top-aligned
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
offsetX: offset,
|
||||
},
|
||||
// Priority 6: Right of trigger, bottom-aligned
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
offsetX: offset,
|
||||
},
|
||||
// Priority 7: Left of trigger, top-aligned
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
offsetX: -offset,
|
||||
},
|
||||
// Priority 8: Left of trigger, bottom-aligned
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
offsetX: -offset,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Standard dropdown overlay positions with default offset (12px).
|
||||
* Use this for most dropdown/menu components.
|
||||
*/
|
||||
export const DROPDOWN_OVERLAY_POSITIONS: ConnectedPosition[] =
|
||||
createOverlayPositions(OVERLAY_OFFSET);
|
||||
Reference in New Issue
Block a user