mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
Compare commits
5 Commits
7200eaefbf
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b653073b | ||
|
|
de3edaa0f9 | ||
|
|
964a6026a0 | ||
|
|
83ad5f526e | ||
|
|
ccc5285602 |
@@ -166,6 +166,11 @@ export class DetailsMainViewBillingAddressesComponent
|
|||||||
customer as unknown as Customer,
|
customer as unknown as Customer,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Clear the selected payer ID when using customer address
|
||||||
|
this.crmTabMetadataService.setSelectedPayerId(
|
||||||
|
this.tabId(),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,11 @@ export class DetailsMainViewDeliveryAddressesComponent
|
|||||||
customer as unknown as Customer,
|
customer as unknown as Customer,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Clear the selected shipping address ID when using customer address
|
||||||
|
this.crmTabMetadataService.setSelectedShippingAddressId(
|
||||||
|
this.tabId(),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import {
|
|||||||
} from '@isa/checkout/data-access';
|
} 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.
|
* Items are only considered identical if all three match.
|
||||||
*/
|
*/
|
||||||
export const getItemKey = (item: ShoppingCartItem): string => {
|
export const getItemKey = (item: ShoppingCartItem): string => {
|
||||||
const ean = item.product.ean ?? 'no-ean';
|
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';
|
const orderType = getOrderTypeFeature(item.features) ?? 'no-orderType';
|
||||||
return `${ean}|${destinationId}|${orderType}`;
|
return `${ean}|${targetBranchId}|${orderType}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export class RewardShoppingCartItemQuantityControlComponent {
|
|||||||
if (
|
if (
|
||||||
orderType === OrderTypeFeature.Delivery ||
|
orderType === OrderTypeFeature.Delivery ||
|
||||||
orderType === OrderTypeFeature.DigitalShipping ||
|
orderType === OrderTypeFeature.DigitalShipping ||
|
||||||
orderType === OrderTypeFeature.B2BShipping
|
orderType === OrderTypeFeature.B2BShipping ||
|
||||||
|
orderType === OrderTypeFeature.Pickup
|
||||||
) {
|
) {
|
||||||
return 999;
|
return 999;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (quantityControl.maxQuantity() < 2 && !isDownload()) {
|
@if (!isDownload()) {
|
||||||
<div
|
@if (showLowStockMessage()) {
|
||||||
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-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>
|
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
|
||||||
</div>
|
<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,6 +72,16 @@ export class RewardShoppingCartItemComponent {
|
|||||||
hasOrderTypeFeature(this.item().features, ['Download']),
|
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() {
|
async updatePurchaseOption() {
|
||||||
const shoppingCartItemId = this.itemId();
|
const shoppingCartItemId = this.itemId();
|
||||||
const shoppingCartId = this.shoppingCartId();
|
const shoppingCartId = this.shoppingCartId();
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
:host {
|
: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()) {
|
@if (store.totalLoyaltyPointsNeeded() > store.customerRewardPoints()) {
|
||||||
<ng-icon
|
<div class="flex flex-row gap-2 items-center">
|
||||||
class="w-6 h-6 inline-flex items-center justify-center"
|
<ng-icon
|
||||||
size="1.5rem"
|
class="w-6 h-6 inline-flex items-center justify-center"
|
||||||
name="isaOtherInfo"
|
size="1.5rem"
|
||||||
></ng-icon>
|
name="isaOtherInfo"
|
||||||
<span>Lesepunkte reichen nicht für alle Artikel</span>
|
></ng-icon>
|
||||||
|
<span>Lesepunkte reichen nicht für alle Artikel</span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,7 @@ export class RewardSelectionInputsComponent {
|
|||||||
|
|
||||||
hasCorrectOrderType = computed(() => {
|
hasCorrectOrderType = computed(() => {
|
||||||
const item = this.rewardSelectionItem().item;
|
const item = this.rewardSelectionItem().item;
|
||||||
return hasOrderTypeFeature(item.features, [
|
return hasOrderTypeFeature(item.features, [OrderTypeFeature.InStore]);
|
||||||
OrderTypeFeature.InStore,
|
|
||||||
OrderTypeFeature.Pickup,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
hasStock = computed(() => {
|
hasStock = computed(() => {
|
||||||
|
|||||||
@@ -27,3 +27,16 @@
|
|||||||
|
|
||||||
<lib-reward-selection-inputs></lib-reward-selection-inputs>
|
<lib-reward-selection-inputs></lib-reward-selection-inputs>
|
||||||
</div>
|
</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 { ProductImageDirective } from '@isa/shared/product-image';
|
||||||
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
|
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
|
||||||
import { RewardSelectionInputsComponent } from './reward-selection-inputs/reward-selection-inputs.component';
|
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({
|
@Component({
|
||||||
selector: 'lib-reward-selection-item',
|
selector: 'lib-reward-selection-item',
|
||||||
@@ -13,8 +23,24 @@ import { RewardSelectionItem } from '@isa/checkout/data-access';
|
|||||||
ProductImageDirective,
|
ProductImageDirective,
|
||||||
ProductRouterLinkDirective,
|
ProductRouterLinkDirective,
|
||||||
RewardSelectionInputsComponent,
|
RewardSelectionInputsComponent,
|
||||||
|
NgIcon,
|
||||||
],
|
],
|
||||||
|
providers: [provideIcons({ isaOtherInfo })],
|
||||||
})
|
})
|
||||||
export class RewardSelectionItemComponent {
|
export class RewardSelectionItemComponent {
|
||||||
rewardSelectionItem = input.required<RewardSelectionItem>();
|
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);
|
return throwError(() => err);
|
||||||
}),
|
}),
|
||||||
mergeMap((response) => {
|
mergeMap((response) => {
|
||||||
if (isResponseArgs(response) && response.error === true) {
|
if (isResponseArgs(response)) {
|
||||||
return throwError(() => new ResponseArgsError(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];
|
return [response];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, inject, resource, signal, computed } from '@angular/core';
|
|||||||
import { logger } from '@isa/core/logging';
|
import { logger } from '@isa/core/logging';
|
||||||
import { CustomerBonRedemptionFacade } from '../facades/customer-bon-redemption.facade';
|
import { CustomerBonRedemptionFacade } from '../facades/customer-bon-redemption.facade';
|
||||||
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
|
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
|
||||||
|
import { ResponseArgsError } from '@isa/common/data-access';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resource for checking/validating Bon numbers.
|
* Resource for checking/validating Bon numbers.
|
||||||
@@ -47,8 +48,24 @@ export class CustomerBonCheckResource {
|
|||||||
this.#logger.debug('Bon checked', () => ({
|
this.#logger.debug('Bon checked', () => ({
|
||||||
bonNr,
|
bonNr,
|
||||||
found: !!response?.result,
|
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;
|
return response?.result;
|
||||||
},
|
},
|
||||||
defaultValue: undefined,
|
defaultValue: undefined,
|
||||||
|
|||||||
@@ -224,15 +224,10 @@ export class CrmSearchService {
|
|||||||
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const res = await firstValueFrom(req$);
|
||||||
const res = await firstValueFrom(req$);
|
this.#logger.debug('Successfully fetched current booking partner store');
|
||||||
this.#logger.debug('Successfully fetched current booking partner store');
|
|
||||||
|
|
||||||
return res?.result;
|
return res?.result;
|
||||||
} catch (error) {
|
|
||||||
this.#logger.error('Error fetching current booking partner store', error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addBooking(
|
async addBooking(
|
||||||
|
|||||||
@@ -114,12 +114,7 @@ export class CrmFeatureCustomerBonRedemptionComponent {
|
|||||||
}
|
}
|
||||||
// Handle API errors
|
// Handle API errors
|
||||||
else if (error) {
|
else if (error) {
|
||||||
let errorMsg = 'Bon-Validierung fehlgeschlagen';
|
const errorMsg = this.#extractErrorMessage(error);
|
||||||
if (error instanceof ResponseArgsError) {
|
|
||||||
errorMsg = error.message || errorMsg;
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMsg = error.message;
|
|
||||||
}
|
|
||||||
this.store.setError(errorMsg);
|
this.store.setError(errorMsg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -224,4 +219,23 @@ export class CrmFeatureCustomerBonRedemptionComponent {
|
|||||||
this.store.reset();
|
this.store.reset();
|
||||||
this.#bonCheckResource.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,18 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
uiButton
|
||||||
|
color="tertiary"
|
||||||
|
size="large"
|
||||||
|
(click)="abortRemission()"
|
||||||
|
class="fixed right-[15rem] bottom-6"
|
||||||
|
>
|
||||||
|
Warenbegleitschein abbrechen
|
||||||
|
</button>
|
||||||
|
|
||||||
@if (!returnLoading() && !returnData()?.completed) {
|
@if (!returnLoading() && !returnData()?.completed) {
|
||||||
<lib-remission-return-receipt-complete
|
<lib-remission-return-receipt-complete
|
||||||
[returnId]="returnId()"
|
[returnId]="returnId()"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
getPackageNumbersFromReturn,
|
getPackageNumbersFromReturn,
|
||||||
getReceiptItemsFromReturn,
|
getReceiptItemsFromReturn,
|
||||||
getReceiptNumberFromReturn,
|
getReceiptNumberFromReturn,
|
||||||
|
RemissionStore,
|
||||||
} from '@isa/remission/data-access';
|
} from '@isa/remission/data-access';
|
||||||
import { EmptyStateComponent } from '@isa/ui/empty-state';
|
import { EmptyStateComponent } from '@isa/ui/empty-state';
|
||||||
import { EMPTY_WBS_DESCRIPTION, EMPTY_WBS_TITLE } from './constants';
|
import { EMPTY_WBS_DESCRIPTION, EMPTY_WBS_TITLE } from './constants';
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
RemissionReturnReceiptActionsComponent,
|
RemissionReturnReceiptActionsComponent,
|
||||||
RemissionReturnReceiptCompleteComponent,
|
RemissionReturnReceiptCompleteComponent,
|
||||||
} from '@isa/remission/shared/return-receipt-actions';
|
} from '@isa/remission/shared/return-receipt-actions';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { injectTabId } from '@isa/core/tabs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'remi-remission-return-receipt-details',
|
selector: 'remi-remission-return-receipt-details',
|
||||||
@@ -53,6 +56,15 @@ export class RemissionReturnReceiptDetailsComponent {
|
|||||||
/** Angular Location service for navigation */
|
/** Angular Location service for navigation */
|
||||||
location = inject(Location);
|
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.
|
* Required input for the return ID.
|
||||||
* Automatically coerced to a number from string input.
|
* Automatically coerced to a number from string input.
|
||||||
@@ -111,4 +123,9 @@ export class RemissionReturnReceiptDetailsComponent {
|
|||||||
const returnData = this.returnData();
|
const returnData = this.returnData();
|
||||||
return getPackageNumbersFromReturn(returnData!) !== '';
|
return getPackageNumbersFromReturn(returnData!) !== '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async abortRemission() {
|
||||||
|
this.#store.clearState();
|
||||||
|
await this.#router.navigate(['/', this.#tabId(), 'remission']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,13 @@
|
|||||||
cdkConnectedOverlay
|
cdkConnectedOverlay
|
||||||
[cdkConnectedOverlayOrigin]="trigger"
|
[cdkConnectedOverlayOrigin]="trigger"
|
||||||
[cdkConnectedOverlayOpen]="open()"
|
[cdkConnectedOverlayOpen]="open()"
|
||||||
|
[cdkConnectedOverlayPositions]="overlayPositions"
|
||||||
[cdkConnectedOverlayHasBackdrop]="true"
|
[cdkConnectedOverlayHasBackdrop]="true"
|
||||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||||
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
|
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
|
||||||
[cdkConnectedOverlayOffsetX]="-10"
|
[cdkConnectedOverlayFlexibleDimensions]="true"
|
||||||
[cdkConnectedOverlayOffsetY]="18"
|
[cdkConnectedOverlayGrowAfterOpen]="true"
|
||||||
|
[cdkConnectedOverlayPush]="true"
|
||||||
cdkConnectedOverlayWidth="18.375rem"
|
cdkConnectedOverlayWidth="18.375rem"
|
||||||
(backdropClick)="toggle()"
|
(backdropClick)="toggle()"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { Overlay, OverlayModule } from '@angular/cdk/overlay';
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
model,
|
model,
|
||||||
output,
|
output,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||||
|
import { DROPDOWN_OVERLAY_POSITIONS } from '@isa/ui/layout';
|
||||||
import { FilterMenuComponent } from './filter-menu.component';
|
import { FilterMenuComponent } from './filter-menu.component';
|
||||||
import { FilterService } from '../../core';
|
import { FilterService } from '../../core';
|
||||||
|
|
||||||
@@ -30,6 +30,9 @@ export class FilterMenuButtonComponent {
|
|||||||
|
|
||||||
selectedFilters = this.#filter.selectedFilterCount;
|
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.
|
* Tracks the open state of the filter menu.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
[cdkConnectedOverlayDisableClose]="false"
|
[cdkConnectedOverlayDisableClose]="false"
|
||||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||||
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
|
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
|
||||||
[cdkConnectedOverlayLockPosition]="true"
|
[cdkConnectedOverlayFlexibleDimensions]="true"
|
||||||
|
[cdkConnectedOverlayGrowAfterOpen]="true"
|
||||||
|
[cdkConnectedOverlayPush]="true"
|
||||||
[cdkConnectedOverlayScrollStrategy]="blockScrollStrategy"
|
[cdkConnectedOverlayScrollStrategy]="blockScrollStrategy"
|
||||||
(backdropClick)="close()"
|
(backdropClick)="close()"
|
||||||
(detach)="isOpen.set(false)"
|
(detach)="isOpen.set(false)"
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||||
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
|
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
|
||||||
@@ -23,14 +21,15 @@ import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
|
|||||||
import {
|
import {
|
||||||
CdkConnectedOverlay,
|
CdkConnectedOverlay,
|
||||||
CdkOverlayOrigin,
|
CdkOverlayOrigin,
|
||||||
ConnectedPosition,
|
|
||||||
ScrollStrategyOptions,
|
ScrollStrategyOptions,
|
||||||
} from '@angular/cdk/overlay';
|
} from '@angular/cdk/overlay';
|
||||||
import { DropdownAppearance } from './dropdown.types';
|
import { DropdownAppearance } from './dropdown.types';
|
||||||
import { DropdownService } from './dropdown.service';
|
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 { logger } from '@isa/core/logging';
|
||||||
|
|
||||||
import { DropdownOptionComponent } from './dropdown-option.component';
|
import { DropdownOptionComponent } from './dropdown-option.component';
|
||||||
import { DropdownFilterComponent } from './dropdown-filter.component';
|
import { DropdownFilterComponent } from './dropdown-filter.component';
|
||||||
import { DROPDOWN_HOST, DropdownHost } from './dropdown-host';
|
import { DROPDOWN_HOST, DropdownHost } from './dropdown-host';
|
||||||
@@ -99,80 +98,8 @@ export class DropdownButtonComponent<T>
|
|||||||
return this.#scrollStrategy.block();
|
return this.#scrollStrategy.block();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Offset in pixels between the trigger and the overlay panel */
|
/** Standard overlay positions for the dropdown panel */
|
||||||
readonly #overlayOffset = 12;
|
readonly overlayPositions = DROPDOWN_OVERLAY_POSITIONS;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
|
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './lib/breakpoint.directive';
|
|||||||
export * from './lib/breakpoint';
|
export * from './lib/breakpoint';
|
||||||
export * from './lib/close-on-scroll.directive';
|
export * from './lib/close-on-scroll.directive';
|
||||||
export * from './lib/in-viewport.directive';
|
export * from './lib/in-viewport.directive';
|
||||||
|
export * from './lib/overlay-positions';
|
||||||
|
|||||||
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