mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
Merged PR 1879: Warenbegleitschein Übersicht und Details
Related work items: #5137, #5138
This commit is contained in:
147
.gitignore
vendored
147
.gitignore
vendored
@@ -1,72 +1,75 @@
|
|||||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||||
|
|
||||||
.matomo
|
.matomo
|
||||||
junit.xml
|
junit.xml
|
||||||
|
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
/tmp
|
/tmp
|
||||||
/out-tsc
|
/out-tsc
|
||||||
|
|
||||||
/
|
/
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
# profiling files
|
# profiling files
|
||||||
chrome-profiler-events.json
|
chrome-profiler-events.json
|
||||||
speed-measure-plugin.json
|
speed-measure-plugin.json
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
/.idea
|
/.idea
|
||||||
.project
|
.project
|
||||||
.classpath
|
.classpath
|
||||||
.c9/
|
.c9/
|
||||||
*.launch
|
*.launch
|
||||||
.settings/
|
.settings/
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
|
|
||||||
# IDE - VSCode
|
# IDE - VSCode
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
!.vscode/tasks.json
|
!.vscode/tasks.json
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.history/*
|
.history/*
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
/.angular/cache
|
/.angular/cache
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/testresults
|
/testresults
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
yarn.lock
|
yarn.lock
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
libs/swagger/src/lib/*
|
libs/swagger/src/lib/*
|
||||||
*storybook.log
|
*storybook.log
|
||||||
|
|
||||||
|
|
||||||
.nx/cache
|
.nx/cache
|
||||||
.nx/workspace-data
|
.nx/workspace-data
|
||||||
.angular
|
.angular
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
||||||
.cursor\rules\nx-rules.mdc
|
.cursor\rules\nx-rules.mdc
|
||||||
.github\instructions\nx.instructions.md
|
.github\instructions\nx.instructions.md
|
||||||
.cursor/rules/nx-rules.mdc
|
.cursor/rules/nx-rules.mdc
|
||||||
.github/instructions/nx.instructions.md
|
.github/instructions/nx.instructions.md
|
||||||
|
|
||||||
vite.config.*.timestamp*
|
vite.config.*.timestamp*
|
||||||
vitest.config.*.timestamp*
|
vitest.config.*.timestamp*
|
||||||
|
|
||||||
|
.mcp.json
|
||||||
|
.memory.json
|
||||||
|
|||||||
@@ -1,214 +1,231 @@
|
|||||||
import { isDevMode, NgModule } from '@angular/core';
|
import { isDevMode, NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
CanActivateCartGuard,
|
CanActivateCartGuard,
|
||||||
CanActivateCartWithProcessIdGuard,
|
CanActivateCartWithProcessIdGuard,
|
||||||
CanActivateCustomerGuard,
|
CanActivateCustomerGuard,
|
||||||
CanActivateCustomerOrdersGuard,
|
CanActivateCustomerOrdersGuard,
|
||||||
CanActivateCustomerOrdersWithProcessIdGuard,
|
CanActivateCustomerOrdersWithProcessIdGuard,
|
||||||
CanActivateCustomerWithProcessIdGuard,
|
CanActivateCustomerWithProcessIdGuard,
|
||||||
CanActivateGoodsInGuard,
|
CanActivateGoodsInGuard,
|
||||||
CanActivateProductGuard,
|
CanActivateProductGuard,
|
||||||
CanActivateProductWithProcessIdGuard,
|
CanActivateProductWithProcessIdGuard,
|
||||||
CanActivateRemissionGuard,
|
CanActivateRemissionGuard,
|
||||||
CanActivateTaskCalendarGuard,
|
CanActivateTaskCalendarGuard,
|
||||||
IsAuthenticatedGuard,
|
IsAuthenticatedGuard,
|
||||||
} from './guards';
|
} from './guards';
|
||||||
import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard';
|
import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard';
|
||||||
import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard';
|
import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard';
|
||||||
import { MainComponent } from './main.component';
|
import { MainComponent } from './main.component';
|
||||||
import { PreviewComponent } from './preview';
|
import { PreviewComponent } from './preview';
|
||||||
import {
|
import {
|
||||||
BranchSectionResolver,
|
BranchSectionResolver,
|
||||||
CustomerSectionResolver,
|
CustomerSectionResolver,
|
||||||
ProcessIdResolver,
|
ProcessIdResolver,
|
||||||
} from './resolvers';
|
} from './resolvers';
|
||||||
import { TokenLoginComponent, TokenLoginModule } from './token-login';
|
import { TokenLoginComponent, TokenLoginModule } from './token-login';
|
||||||
import { ProcessIdGuard } from './guards/process-id.guard';
|
import { ProcessIdGuard } from './guards/process-id.guard';
|
||||||
import {
|
import {
|
||||||
ActivateProcessIdGuard,
|
ActivateProcessIdGuard,
|
||||||
ActivateProcessIdWithConfigKeyGuard,
|
ActivateProcessIdWithConfigKeyGuard,
|
||||||
} from './guards/activate-process-id.guard';
|
} from './guards/activate-process-id.guard';
|
||||||
import { MatomoRouteData } from 'ngx-matomo-client';
|
import { MatomoRouteData } from 'ngx-matomo-client';
|
||||||
import { tabResolverFn } from '@isa/core/tabs';
|
import { tabResolverFn } from '@isa/core/tabs';
|
||||||
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
|
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'kunde/dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: 'kunde/dashboard', pathMatch: 'full' },
|
||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
children: [
|
children: [
|
||||||
{ path: ':token', component: TokenLoginComponent },
|
{ path: ':token', component: TokenLoginComponent },
|
||||||
{ path: '**', redirectTo: 'kunde', pathMatch: 'full' },
|
{ path: '**', redirectTo: 'kunde', pathMatch: 'full' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
canActivate: [IsAuthenticatedGuard],
|
canActivate: [IsAuthenticatedGuard],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'kunde',
|
path: 'kunde',
|
||||||
component: MainComponent,
|
component: MainComponent,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/dashboard').then((m) => m.DashboardModule),
|
import('@page/dashboard').then((m) => m.DashboardModule),
|
||||||
data: {
|
data: {
|
||||||
matomo: {
|
matomo: {
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
} as MatomoRouteData,
|
} as MatomoRouteData,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'product',
|
path: 'product',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/catalog').then((m) => m.PageCatalogModule),
|
import('@page/catalog').then((m) => m.PageCatalogModule),
|
||||||
canActivate: [CanActivateProductGuard],
|
canActivate: [CanActivateProductGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':processId/product',
|
path: ':processId/product',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/catalog').then((m) => m.PageCatalogModule),
|
import('@page/catalog').then((m) => m.PageCatalogModule),
|
||||||
canActivate: [CanActivateProductWithProcessIdGuard],
|
canActivate: [CanActivateProductWithProcessIdGuard],
|
||||||
resolve: { processId: ProcessIdResolver },
|
resolve: { processId: ProcessIdResolver },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'order',
|
path: 'order',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/customer-order').then((m) => m.CustomerOrderModule),
|
import('@page/customer-order').then((m) => m.CustomerOrderModule),
|
||||||
canActivate: [CanActivateCustomerOrdersGuard],
|
canActivate: [CanActivateCustomerOrdersGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':processId/order',
|
path: ':processId/order',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/customer-order').then((m) => m.CustomerOrderModule),
|
import('@page/customer-order').then((m) => m.CustomerOrderModule),
|
||||||
canActivate: [CanActivateCustomerOrdersWithProcessIdGuard],
|
canActivate: [CanActivateCustomerOrdersWithProcessIdGuard],
|
||||||
resolve: { processId: ProcessIdResolver },
|
resolve: { processId: ProcessIdResolver },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'customer',
|
path: 'customer',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/customer').then((m) => m.CustomerModule),
|
import('@page/customer').then((m) => m.CustomerModule),
|
||||||
canActivate: [CanActivateCustomerGuard],
|
canActivate: [CanActivateCustomerGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':processId/customer',
|
path: ':processId/customer',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/customer').then((m) => m.CustomerModule),
|
import('@page/customer').then((m) => m.CustomerModule),
|
||||||
canActivate: [CanActivateCustomerWithProcessIdGuard],
|
canActivate: [CanActivateCustomerWithProcessIdGuard],
|
||||||
resolve: { processId: ProcessIdResolver },
|
resolve: { processId: ProcessIdResolver },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'cart',
|
path: 'cart',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/checkout').then((m) => m.PageCheckoutModule),
|
import('@page/checkout').then((m) => m.PageCheckoutModule),
|
||||||
canActivate: [CanActivateCartGuard],
|
canActivate: [CanActivateCartGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':processId/cart',
|
path: ':processId/cart',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/checkout').then((m) => m.PageCheckoutModule),
|
import('@page/checkout').then((m) => m.PageCheckoutModule),
|
||||||
canActivate: [CanActivateCartWithProcessIdGuard],
|
canActivate: [CanActivateCartWithProcessIdGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'pickup-shelf',
|
path: 'pickup-shelf',
|
||||||
canActivate: [ProcessIdGuard],
|
canActivate: [ProcessIdGuard],
|
||||||
// NOTE: This is a workaround for the canActivate guard not being called
|
// NOTE: This is a workaround for the canActivate guard not being called
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
|
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':processId/pickup-shelf',
|
path: ':processId/pickup-shelf',
|
||||||
canActivate: [ActivateProcessIdGuard],
|
canActivate: [ActivateProcessIdGuard],
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
|
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
|
||||||
},
|
},
|
||||||
{ path: '**', redirectTo: 'dashboard', pathMatch: 'full' },
|
{ path: '**', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
],
|
],
|
||||||
resolve: { section: CustomerSectionResolver },
|
resolve: { section: CustomerSectionResolver },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'filiale',
|
path: 'filiale',
|
||||||
component: MainComponent,
|
component: MainComponent,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'task-calendar',
|
path: 'task-calendar',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/task-calendar').then(
|
import('@page/task-calendar').then(
|
||||||
(m) => m.PageTaskCalendarModule,
|
(m) => m.PageTaskCalendarModule,
|
||||||
),
|
),
|
||||||
canActivate: [CanActivateTaskCalendarGuard],
|
canActivate: [CanActivateTaskCalendarGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'pickup-shelf',
|
path: 'pickup-shelf',
|
||||||
canActivate: [ActivateProcessIdWithConfigKeyGuard('pickupShelf')],
|
canActivate: [ActivateProcessIdWithConfigKeyGuard('pickupShelf')],
|
||||||
// NOTE: This is a workaround for the canActivate guard not being called
|
// NOTE: This is a workaround for the canActivate guard not being called
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/pickup-shelf').then((m) => m.PickupShelfInModule),
|
import('@page/pickup-shelf').then((m) => m.PickupShelfInModule),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'goods/in',
|
path: 'goods/in',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/goods-in').then((m) => m.GoodsInModule),
|
import('@page/goods-in').then((m) => m.GoodsInModule),
|
||||||
canActivate: [CanActivateGoodsInGuard],
|
canActivate: [CanActivateGoodsInGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'remission',
|
path: 'remission',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/remission').then((m) => m.PageRemissionModule),
|
import('@page/remission').then((m) => m.PageRemissionModule),
|
||||||
canActivate: [CanActivateRemissionGuard],
|
canActivate: [CanActivateRemissionGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'package-inspection',
|
path: 'package-inspection',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/package-inspection').then(
|
import('@page/package-inspection').then(
|
||||||
(m) => m.PackageInspectionModule,
|
(m) => m.PackageInspectionModule,
|
||||||
),
|
),
|
||||||
canActivate: [CanActivatePackageInspectionGuard],
|
canActivate: [CanActivatePackageInspectionGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'assortment',
|
path: 'assortment',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@page/assortment').then((m) => m.AssortmentModule),
|
import('@page/assortment').then((m) => m.AssortmentModule),
|
||||||
canActivate: [CanActivateAssortmentGuard],
|
canActivate: [CanActivateAssortmentGuard],
|
||||||
},
|
},
|
||||||
{ path: '**', redirectTo: 'task-calendar', pathMatch: 'full' },
|
{ path: '**', redirectTo: 'task-calendar', pathMatch: 'full' },
|
||||||
],
|
],
|
||||||
resolve: { section: BranchSectionResolver },
|
resolve: { section: BranchSectionResolver },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':tabId',
|
path: ':tabId',
|
||||||
component: MainComponent,
|
component: MainComponent,
|
||||||
resolve: { process: tabResolverFn, tab: tabResolverFn },
|
resolve: { process: tabResolverFn, tab: tabResolverFn },
|
||||||
canActivate: [IsAuthenticatedGuard],
|
canActivate: [IsAuthenticatedGuard],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'return',
|
path: 'return',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('@isa/oms/feature/return-search').then((m) => m.routes),
|
import('@isa/oms/feature/return-search').then((m) => m.routes),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'remission',
|
path: 'remission',
|
||||||
loadChildren: () =>
|
children: [
|
||||||
import('@isa/remission/feature/remission-list').then((m) => m.routes),
|
{
|
||||||
},
|
path: 'return-receipt',
|
||||||
],
|
loadChildren: () =>
|
||||||
},
|
import(
|
||||||
];
|
'@isa/remission/feature/remission-return-receipt-list'
|
||||||
|
).then((m) => m.routes),
|
||||||
if (isDevMode()) {
|
},
|
||||||
routes.unshift({
|
{
|
||||||
path: 'preview',
|
path: '',
|
||||||
component: PreviewComponent,
|
loadChildren: () =>
|
||||||
});
|
import('@isa/remission/feature/remission-list').then(
|
||||||
}
|
(m) => m.routes,
|
||||||
|
),
|
||||||
@NgModule({
|
},
|
||||||
imports: [RouterModule.forRoot(routes), TokenLoginModule],
|
],
|
||||||
exports: [RouterModule],
|
},
|
||||||
providers: [provideScrollPositionRestoration()],
|
],
|
||||||
})
|
},
|
||||||
export class AppRoutingModule {}
|
];
|
||||||
|
|
||||||
|
if (isDevMode()) {
|
||||||
|
routes.unshift({
|
||||||
|
path: 'preview',
|
||||||
|
component: PreviewComponent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forRoot(routes, { bindToComponentInputs: true }),
|
||||||
|
TokenLoginModule,
|
||||||
|
],
|
||||||
|
exports: [RouterModule],
|
||||||
|
providers: [provideScrollPositionRestoration()],
|
||||||
|
})
|
||||||
|
export class AppRoutingModule {}
|
||||||
|
|||||||
@@ -1,303 +1,319 @@
|
|||||||
<div class="side-menu-group">
|
<div class="side-menu-group">
|
||||||
<span class="side-menu-group-label">Kunden</span>
|
<span class="side-menu-group-label">Kunden</span>
|
||||||
<nav class="side-menu-group-nav">
|
<nav class="side-menu-group-nav">
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); resetBranch(); focusSearchBox()"
|
(click)="closeSideMenu(); resetBranch(); focusSearchBox()"
|
||||||
[routerLink]="productRoutePath$ | async"
|
[routerLink]="productRoutePath$ | async"
|
||||||
sharedRegexRouterLinkActive="active"
|
sharedRegexRouterLinkActive="active"
|
||||||
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/product"
|
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/product"
|
||||||
(isActiveChange)="focusSearchBox()"
|
(isActiveChange)="focusSearchBox()"
|
||||||
>
|
>
|
||||||
<div class="side-menu-group-item-icon">
|
<div class="side-menu-group-item-icon">
|
||||||
<shared-icon icon="import-contacts"></shared-icon>
|
<shared-icon icon="import-contacts"></shared-icon>
|
||||||
</div>
|
</div>
|
||||||
<span class="side-menu-group-item-label">Artikelsuche</span>
|
<span class="side-menu-group-item-label">Artikelsuche</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="side-menu-group-sub-item-wrapper">
|
<div class="side-menu-group-sub-item-wrapper">
|
||||||
@if (customerSearchRoute$ | async; as customerSearchRoute) {
|
@if (customerSearchRoute$ | async; as customerSearchRoute) {
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); focusSearchBox()"
|
(click)="closeSideMenu(); focusSearchBox()"
|
||||||
[routerLink]="customerSearchRoute.path"
|
[routerLink]="customerSearchRoute.path"
|
||||||
[queryParams]="customerSearchRoute.queryParams"
|
[queryParams]="customerSearchRoute.queryParams"
|
||||||
sharedRegexRouterLinkActive="active"
|
sharedRegexRouterLinkActive="active"
|
||||||
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer"
|
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer"
|
||||||
(isActiveChange)="customerActive($event); focusSearchBox()"
|
(isActiveChange)="customerActive($event); focusSearchBox()"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon">
|
<span class="side-menu-group-item-icon">
|
||||||
<shared-icon icon="person"></shared-icon>
|
<shared-icon icon="person"></shared-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="side-menu-group-item-label">Kunden</span>
|
<span class="side-menu-group-item-label">Kunden</span>
|
||||||
<button
|
<button
|
||||||
class="side-menu-group-arrow"
|
class="side-menu-group-arrow"
|
||||||
[class.side-menu-item-rotate]="customerExpanded"
|
[class.side-menu-item-rotate]="customerExpanded"
|
||||||
(click)="
|
(click)="
|
||||||
$event.stopPropagation();
|
$event.stopPropagation();
|
||||||
$event.preventDefault();
|
$event.preventDefault();
|
||||||
customerExpanded = !customerExpanded
|
customerExpanded = !customerExpanded
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<shared-icon icon="keyboard-arrow-down"></shared-icon>
|
<shared-icon icon="keyboard-arrow-down"></shared-icon>
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="side-menu-group-sub-items" [class.hidden]="!customerExpanded">
|
<div class="side-menu-group-sub-items" [class.hidden]="!customerExpanded">
|
||||||
@if (customerSearchRoute$ | async; as customerSearchRoute) {
|
@if (customerSearchRoute$ | async; as customerSearchRoute) {
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); focusSearchBox()"
|
(click)="closeSideMenu(); focusSearchBox()"
|
||||||
[routerLink]="customerSearchRoute.path"
|
[routerLink]="customerSearchRoute.path"
|
||||||
[queryParams]="customerSearchRoute.queryParams"
|
[queryParams]="customerSearchRoute.queryParams"
|
||||||
sharedRegexRouterLinkActive="active"
|
sharedRegexRouterLinkActive="active"
|
||||||
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer\/(\(search|search)"
|
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer\/(\(search|search)"
|
||||||
(isActiveChange)="focusSearchBox()"
|
(isActiveChange)="focusSearchBox()"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon"></span>
|
<span class="side-menu-group-item-icon"></span>
|
||||||
<span class="side-menu-group-item-label">Suchen</span>
|
<span class="side-menu-group-item-label">Suchen</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@if (customerCreateRoute$ | async; as customerCreateRoute) {
|
@if (customerCreateRoute$ | async; as customerCreateRoute) {
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu()"
|
(click)="closeSideMenu()"
|
||||||
[routerLink]="customerCreateRoute.path"
|
[routerLink]="customerCreateRoute.path"
|
||||||
[queryParams]="customerCreateRoute.queryParams"
|
[queryParams]="customerCreateRoute.queryParams"
|
||||||
sharedRegexRouterLinkActive="active"
|
sharedRegexRouterLinkActive="active"
|
||||||
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer\/(\(create|create)"
|
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer\/(\(create|create)"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon"></span>
|
<span class="side-menu-group-item-icon"></span>
|
||||||
<span class="side-menu-group-item-label">Erfassen</span>
|
<span class="side-menu-group-item-label">Erfassen</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
*ifRole="'Store'"
|
*ifRole="'Store'"
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); focusSearchBox()"
|
(click)="closeSideMenu(); focusSearchBox()"
|
||||||
[routerLink]="pickUpShelfOutRoutePath$ | async"
|
[routerLink]="pickUpShelfOutRoutePath$ | async"
|
||||||
sharedRegexRouterLinkActive="active"
|
sharedRegexRouterLinkActive="active"
|
||||||
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/pickup-shelf"
|
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/pickup-shelf"
|
||||||
(isActiveChange)="focusSearchBox()"
|
(isActiveChange)="focusSearchBox()"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon">
|
<span class="side-menu-group-item-icon">
|
||||||
<shared-icon icon="unarchive"></shared-icon>
|
<shared-icon icon="unarchive"></shared-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="side-menu-group-item-label">Warenausgabe</span>
|
<span class="side-menu-group-item-label">Warenausgabe</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
*ifRole="'Store'"
|
*ifRole="'Store'"
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); focusSearchBox()"
|
(click)="closeSideMenu(); focusSearchBox()"
|
||||||
[routerLink]="[
|
[routerLink]="[
|
||||||
'/',
|
'/',
|
||||||
processService.activatedTab()?.id || processService.nextId(),
|
processService.activatedTab()?.id || processService.nextId(),
|
||||||
'return',
|
'return',
|
||||||
]"
|
]"
|
||||||
(isActiveChange)="focusSearchBox()"
|
(isActiveChange)="focusSearchBox()"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
|
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
|
||||||
<ng-icon name="isaNavigationReturn"></ng-icon>
|
<ng-icon name="isaNavigationReturn"></ng-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="side-menu-group-item-label">Retoure</span>
|
<span class="side-menu-group-item-label">Retoure</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
*ifRole="'CallCenter'"
|
*ifRole="'CallCenter'"
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); resetBranch(); focusSearchBox()"
|
(click)="closeSideMenu(); resetBranch(); focusSearchBox()"
|
||||||
[routerLink]="customerOrdersRoutePath$ | async"
|
[routerLink]="customerOrdersRoutePath$ | async"
|
||||||
sharedRegexRouterLinkActive="active"
|
sharedRegexRouterLinkActive="active"
|
||||||
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/order"
|
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/order"
|
||||||
(isActiveChange)="focusSearchBox()"
|
(isActiveChange)="focusSearchBox()"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon">
|
<span class="side-menu-group-item-icon">
|
||||||
<shared-icon icon="deployed-code"></shared-icon>
|
<shared-icon icon="deployed-code"></shared-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="side-menu-group-item-label">Bestellungen</span>
|
<span class="side-menu-group-item-label">Bestellungen</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="side-menu-group" *ifRole="'Store'">
|
<div class="side-menu-group" *ifRole="'Store'">
|
||||||
<span class="side-menu-group-label">Filiale</span>
|
<span class="side-menu-group-label">Filiale</span>
|
||||||
<nav class="side-menu-group-nav">
|
<nav class="side-menu-group-nav">
|
||||||
@if (taskCalenderNavigation$ | async; as taskCalenderNavigation) {
|
@if (taskCalenderNavigation$ | async; as taskCalenderNavigation) {
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); focusSearchBox()"
|
(click)="closeSideMenu(); focusSearchBox()"
|
||||||
[routerLink]="taskCalenderNavigation.path"
|
[routerLink]="taskCalenderNavigation.path"
|
||||||
[queryParams]="taskCalenderNavigation.queryParams"
|
[queryParams]="taskCalenderNavigation.queryParams"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
(isActiveChange)="focusSearchBox()"
|
(isActiveChange)="focusSearchBox()"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon">
|
<span class="side-menu-group-item-icon">
|
||||||
<shared-icon icon="event-available"></shared-icon>
|
<shared-icon icon="event-available"></shared-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="side-menu-group-item-label">Kalender</span>
|
<span class="side-menu-group-item-label">Kalender</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@if (assortmentNavigation$ | async; as assortmentNavigation) {
|
@if (assortmentNavigation$ | async; as assortmentNavigation) {
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu()"
|
(click)="closeSideMenu()"
|
||||||
[routerLink]="assortmentNavigation.path"
|
[routerLink]="assortmentNavigation.path"
|
||||||
[queryParams]="assortmentNavigation.queryParams"
|
[queryParams]="assortmentNavigation.queryParams"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon">
|
<span class="side-menu-group-item-icon">
|
||||||
<shared-icon icon="shape-outline"></shared-icon>
|
<shared-icon icon="shape-outline"></shared-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="side-menu-group-item-label">Sortiment</span>
|
<span class="side-menu-group-item-label">Sortiment</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="side-menu-group-sub-item-wrapper">
|
<div class="side-menu-group-sub-item-wrapper">
|
||||||
@if (pickUpShelfInRoutePath$ | async; as pickUpShelfInNavigation) {
|
@if (pickUpShelfInRoutePath$ | async; as pickUpShelfInNavigation) {
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); focusSearchBox()"
|
(click)="closeSideMenu(); focusSearchBox()"
|
||||||
[routerLink]="pickUpShelfInNavigation.path"
|
[routerLink]="pickUpShelfInNavigation.path"
|
||||||
[queryParams]="pickUpShelfInNavigation.queryParams"
|
[queryParams]="pickUpShelfInNavigation.queryParams"
|
||||||
sharedRegexRouterLinkActive="active"
|
sharedRegexRouterLinkActive="active"
|
||||||
sharedRegexRouterLinkActiveTest="^\/filiale\/(pickup-shelf|goods\/in)"
|
sharedRegexRouterLinkActiveTest="^\/filiale\/(pickup-shelf|goods\/in)"
|
||||||
(isActiveChange)="shelfActive($event); focusSearchBox()"
|
(isActiveChange)="shelfActive($event); focusSearchBox()"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon">
|
<span class="side-menu-group-item-icon">
|
||||||
<shared-icon icon="isa-abholfach"></shared-icon>
|
<shared-icon icon="isa-abholfach"></shared-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="side-menu-group-item-label">Abholfach</span>
|
<span class="side-menu-group-item-label">Abholfach</span>
|
||||||
<button
|
<button
|
||||||
class="side-menu-group-arrow"
|
class="side-menu-group-arrow"
|
||||||
[class.side-menu-item-rotate]="shelfExpanded"
|
[class.side-menu-item-rotate]="shelfExpanded"
|
||||||
(click)="
|
(click)="
|
||||||
$event.stopPropagation();
|
$event.stopPropagation();
|
||||||
$event.preventDefault();
|
$event.preventDefault();
|
||||||
shelfExpanded = !shelfExpanded
|
shelfExpanded = !shelfExpanded
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<shared-icon icon="keyboard-arrow-down"></shared-icon>
|
<shared-icon icon="keyboard-arrow-down"></shared-icon>
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="side-menu-group-sub-items" [class.hidden]="!shelfExpanded">
|
<div class="side-menu-group-sub-items" [class.hidden]="!shelfExpanded">
|
||||||
@if (pickUpShelfInRoutePath$ | async; as pickUpShelfInListNavigation) {
|
@if (pickUpShelfInRoutePath$ | async; as pickUpShelfInListNavigation) {
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); focusSearchBox()"
|
(click)="closeSideMenu(); focusSearchBox()"
|
||||||
[routerLink]="pickUpShelfInListNavigation.path"
|
[routerLink]="pickUpShelfInListNavigation.path"
|
||||||
[queryParams]="pickUpShelfInListNavigation.queryParams"
|
[queryParams]="pickUpShelfInListNavigation.queryParams"
|
||||||
[class.has-child-view]="currentShelfView$ | async"
|
[class.has-child-view]="currentShelfView$ | async"
|
||||||
sharedRegexRouterLinkActive="active"
|
sharedRegexRouterLinkActive="active"
|
||||||
[sharedRegexRouterLinkActiveTest]="'^\/filiale\/pickup-shelf'"
|
[sharedRegexRouterLinkActiveTest]="'^\/filiale\/pickup-shelf'"
|
||||||
(isActiveChange)="shelfActive($event); focusSearchBox()"
|
(isActiveChange)="shelfActive($event); focusSearchBox()"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon"></span>
|
<span class="side-menu-group-item-icon"></span>
|
||||||
<span class="side-menu-group-item-label">Einbuchen</span>
|
<span class="side-menu-group-item-label">Einbuchen</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu()"
|
(click)="closeSideMenu()"
|
||||||
[routerLink]="['/filiale', 'goods', 'in', 'reservation']"
|
[routerLink]="['/filiale', 'goods', 'in', 'reservation']"
|
||||||
[queryParams]="{ view: 'reservation' }"
|
[queryParams]="{ view: 'reservation' }"
|
||||||
[class.active-child]="(currentShelfView$ | async) === 'reservation'"
|
[class.active-child]="(currentShelfView$ | async) === 'reservation'"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
(isActiveChange)="shelfActive($event)"
|
(isActiveChange)="shelfActive($event)"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon"></span>
|
<span class="side-menu-group-item-icon"></span>
|
||||||
<span class="side-menu-group-item-label">Reservierung</span>
|
<span class="side-menu-group-item-label">Reservierung</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu()"
|
(click)="closeSideMenu()"
|
||||||
[routerLink]="['/filiale', 'goods', 'in', 'cleanup']"
|
[routerLink]="['/filiale', 'goods', 'in', 'cleanup']"
|
||||||
[queryParams]="{ view: 'cleanup' }"
|
[queryParams]="{ view: 'cleanup' }"
|
||||||
[class.active-child]="(currentShelfView$ | async) === 'cleanup'"
|
[class.active-child]="(currentShelfView$ | async) === 'cleanup'"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
(isActiveChange)="shelfActive($event)"
|
(isActiveChange)="shelfActive($event)"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon"></span>
|
<span class="side-menu-group-item-icon"></span>
|
||||||
<span class="side-menu-group-item-label">Ausräumen</span>
|
<span class="side-menu-group-item-label">Ausräumen</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu()"
|
(click)="closeSideMenu()"
|
||||||
[routerLink]="['/filiale', 'goods', 'in', 'preview']"
|
[routerLink]="['/filiale', 'goods', 'in', 'preview']"
|
||||||
[queryParams]="{ view: 'remission' }"
|
[queryParams]="{ view: 'remission' }"
|
||||||
[class.active-child]="(currentShelfView$ | async) === 'remission'"
|
[class.active-child]="(currentShelfView$ | async) === 'remission'"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
(isActiveChange)="shelfActive($event)"
|
(isActiveChange)="shelfActive($event)"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon"></span>
|
<span class="side-menu-group-item-icon"></span>
|
||||||
<span class="side-menu-group-item-label">Remi-Vorschau</span>
|
<span class="side-menu-group-item-label">Remi-Vorschau</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu()"
|
(click)="closeSideMenu()"
|
||||||
[routerLink]="['/filiale', 'goods', 'in', 'list']"
|
[routerLink]="['/filiale', 'goods', 'in', 'list']"
|
||||||
[queryParams]="{ view: 'wareneingangsliste' }"
|
[queryParams]="{ view: 'wareneingangsliste' }"
|
||||||
[class.active-child]="
|
[class.active-child]="
|
||||||
(currentShelfView$ | async) === 'wareneingangsliste'
|
(currentShelfView$ | async) === 'wareneingangsliste'
|
||||||
"
|
"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
(isActiveChange)="shelfActive($event)"
|
(isActiveChange)="shelfActive($event)"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon"></span>
|
<span class="side-menu-group-item-icon"></span>
|
||||||
<span class="side-menu-group-item-label">Fehlende</span>
|
<span class="side-menu-group-item-label">Fehlende</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (remissionNavigation$ | async; as remissionNavigation) {
|
@if (remissionNavigation$ | async; as remissionNavigation) {
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu()"
|
(click)="closeSideMenu()"
|
||||||
[routerLink]="remissionNavigation.path"
|
[routerLink]="remissionNavigation.path"
|
||||||
[queryParams]="remissionNavigation.queryParams"
|
[queryParams]="remissionNavigation.queryParams"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon">
|
<span class="side-menu-group-item-icon">
|
||||||
<shared-icon icon="assignment-return"></shared-icon>
|
<shared-icon icon="assignment-return"></shared-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="side-menu-group-item-label">Remission</span>
|
<span class="side-menu-group-item-label">Remission</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
|
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); fetchAndOpenPackages()"
|
(click)="closeSideMenu(); fetchAndOpenPackages()"
|
||||||
[routerLink]="packageInspectionNavigation.path"
|
[routerLink]="packageInspectionNavigation.path"
|
||||||
[queryParams]="packageInspectionNavigation.queryParams"
|
[queryParams]="packageInspectionNavigation.queryParams"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon">
|
<span class="side-menu-group-item-icon">
|
||||||
<shared-icon icon="clipboard-check-outline"></shared-icon>
|
<shared-icon icon="clipboard-check-outline"></shared-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="side-menu-group-item-label">Wareneingang</span>
|
<span class="side-menu-group-item-label">Wareneingang</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
class="side-menu-group-item"
|
class="side-menu-group-item"
|
||||||
(click)="closeSideMenu(); focusSearchBox()"
|
(click)="closeSideMenu(); focusSearchBox()"
|
||||||
[routerLink]="[
|
[routerLink]="[
|
||||||
'/',
|
'/',
|
||||||
processService.activatedTab()?.id || processService.nextId(),
|
processService.activatedTab()?.id || processService.nextId(),
|
||||||
'remission',
|
'remission',
|
||||||
]"
|
]"
|
||||||
(isActiveChange)="focusSearchBox()"
|
(isActiveChange)="focusSearchBox()"
|
||||||
>
|
>
|
||||||
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
|
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
|
||||||
<ng-icon name="isaNavigationRemission2"></ng-icon>
|
<ng-icon name="isaNavigationRemission2"></ng-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="side-menu-group-item-label">Remission</span>
|
<span class="side-menu-group-item-label">Remission</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
<a
|
||||||
</div>
|
class="side-menu-group-item"
|
||||||
|
(click)="closeSideMenu(); focusSearchBox()"
|
||||||
|
[routerLink]="[
|
||||||
|
'/',
|
||||||
|
processService.activatedTab()?.id || processService.nextId(),
|
||||||
|
'remission',
|
||||||
|
'return-receipt',
|
||||||
|
]"
|
||||||
|
(isActiveChange)="focusSearchBox()"
|
||||||
|
>
|
||||||
|
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
|
||||||
|
<ng-icon name="isaNavigationRemission2"></ng-icon>
|
||||||
|
</span>
|
||||||
|
<span class="side-menu-group-item-label">Warenbegleitscheine</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
@import "../../../libs/ui/buttons/src/buttons.scss";
|
@import "../../../libs/ui/buttons/src/buttons.scss";
|
||||||
|
@import "../../../libs/ui/bullet-list/src/bullet-list.scss";
|
||||||
@import "../../../libs/ui/datepicker/src/datepicker.scss";
|
@import "../../../libs/ui/datepicker/src/datepicker.scss";
|
||||||
@import "../../../libs/ui/dialog/src/dialog.scss";
|
@import "../../../libs/ui/dialog/src/dialog.scss";
|
||||||
@import "../../../libs/ui/input-controls/src/input-controls.scss";
|
@import "../../../libs/ui/input-controls/src/input-controls.scss";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
277
libs/common/decorators/README.md
Normal file
277
libs/common/decorators/README.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# Common Decorators Library
|
||||||
|
|
||||||
|
A collection of TypeScript decorators for common cross-cutting concerns in Angular applications.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
This library is already configured in the project's `tsconfig.base.json`. Import decorators using:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { InFlight, InFlightWithKey, InFlightWithCache } from '@isa/common/decorators';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Decorators
|
||||||
|
|
||||||
|
### 🚀 InFlight Decorators
|
||||||
|
|
||||||
|
Prevent multiple simultaneous calls to the same async method. All concurrent calls receive the same Promise result.
|
||||||
|
|
||||||
|
#### Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { InFlight } from '@isa/common/decorators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
class DataService {
|
||||||
|
@InFlight()
|
||||||
|
async fetchData(): Promise<Data> {
|
||||||
|
// Even if called multiple times simultaneously,
|
||||||
|
// only one API call will be made
|
||||||
|
return await this.http.get<Data>('/api/data').toPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Prevents duplicate API calls
|
||||||
|
- Reduces server load
|
||||||
|
- Improves application performance
|
||||||
|
- All callers receive the same result
|
||||||
|
|
||||||
|
### 🔑 InFlightWithKey
|
||||||
|
|
||||||
|
Prevents duplicate calls while considering method arguments. Each unique set of arguments gets its own in-flight tracking.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { InFlightWithKey } from '@isa/common/decorators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
class UserService {
|
||||||
|
@InFlightWithKey({
|
||||||
|
keyGenerator: (userId: string) => userId
|
||||||
|
})
|
||||||
|
async fetchUser(userId: string): Promise<User> {
|
||||||
|
// Multiple calls with same userId share the same request
|
||||||
|
// Different userIds can execute simultaneously
|
||||||
|
return await this.http.get<User>(`/api/users/${userId}`).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
@InFlightWithKey() // Uses JSON.stringify by default
|
||||||
|
async searchUsers(query: string, page: number): Promise<User[]> {
|
||||||
|
return await this.http.get<User[]>(`/api/users/search`, {
|
||||||
|
params: { query, page: page.toString() }
|
||||||
|
}).toPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration Options:**
|
||||||
|
- `keyGenerator?: (...args) => string` - Custom key generation function
|
||||||
|
- If not provided, uses `JSON.stringify(args)` as the key
|
||||||
|
|
||||||
|
### 🗄️ InFlightWithCache
|
||||||
|
|
||||||
|
Combines in-flight request deduplication with result caching.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { InFlightWithCache } from '@isa/common/decorators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
class ProductService {
|
||||||
|
@InFlightWithCache({
|
||||||
|
cacheTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
keyGenerator: (productId: string) => productId
|
||||||
|
})
|
||||||
|
async getProduct(productId: string): Promise<Product> {
|
||||||
|
// Results are cached for 5 minutes
|
||||||
|
// Multiple calls within cache time return cached result
|
||||||
|
return await this.http.get<Product>(`/api/products/${productId}`).toPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration Options:**
|
||||||
|
- `cacheTime?: number` - Cache duration in milliseconds
|
||||||
|
- `keyGenerator?: (...args) => string` - Custom key generation function
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Memory Management
|
||||||
|
|
||||||
|
All decorators use `WeakMap` for memory efficiency:
|
||||||
|
- Automatic garbage collection when instances are destroyed
|
||||||
|
- No memory leaks
|
||||||
|
- Per-instance state isolation
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Failed requests are not cached
|
||||||
|
- In-flight tracking is cleaned up on both success and error
|
||||||
|
- All concurrent callers receive the same error
|
||||||
|
|
||||||
|
### Thread Safety
|
||||||
|
|
||||||
|
- Decorators are instance-aware
|
||||||
|
- Each service instance has its own in-flight tracking
|
||||||
|
- No shared state between instances
|
||||||
|
|
||||||
|
## Real-World Examples
|
||||||
|
|
||||||
|
### Solving Your Original Problem
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before: Multiple simultaneous calls
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RemissionProductGroupService {
|
||||||
|
async fetchProductGroups(): Promise<KeyValueStringAndString[]> {
|
||||||
|
// Multiple calls = multiple API requests
|
||||||
|
return await this.apiCall();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After: Using InFlight decorator
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RemissionProductGroupService {
|
||||||
|
@InFlight()
|
||||||
|
async fetchProductGroups(): Promise<KeyValueStringAndString[]> {
|
||||||
|
// Multiple simultaneous calls = single API request
|
||||||
|
return await this.apiCall();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Scenarios
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Injectable()
|
||||||
|
class OrderService {
|
||||||
|
// Different cache times for different data types
|
||||||
|
@InFlightWithCache({ cacheTime: 30 * 1000 }) // 30 seconds
|
||||||
|
async getOrderStatus(orderId: string): Promise<OrderStatus> {
|
||||||
|
return await this.http.get<OrderStatus>(`/api/orders/${orderId}/status`).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
@InFlightWithCache({ cacheTime: 10 * 60 * 1000 }) // 10 minutes
|
||||||
|
async getOrderHistory(customerId: string): Promise<Order[]> {
|
||||||
|
return await this.http.get<Order[]>(`/api/customers/${customerId}/orders`).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom key generation for complex parameters
|
||||||
|
@InFlightWithKey({
|
||||||
|
keyGenerator: (filter: OrderFilter) =>
|
||||||
|
`${filter.status}-${filter.dateFrom}-${filter.dateTo}`
|
||||||
|
})
|
||||||
|
async searchOrders(filter: OrderFilter): Promise<Order[]> {
|
||||||
|
return await this.http.post<Order[]>('/api/orders/search', filter).toPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### ✅ Do
|
||||||
|
|
||||||
|
- Use `@InFlight()` for simple methods without parameters
|
||||||
|
- Use `@InFlightWithKey()` for methods with parameters
|
||||||
|
- Use `@InFlightWithCache()` for expensive operations with stable results
|
||||||
|
- Provide custom `keyGenerator` for complex parameter objects
|
||||||
|
- Set appropriate cache times based on data volatility
|
||||||
|
|
||||||
|
### ❌ Don't
|
||||||
|
|
||||||
|
- Use on methods that return different results for the same input
|
||||||
|
- Use excessively long cache times for dynamic data
|
||||||
|
- Use on methods that have side effects (POST, PUT, DELETE)
|
||||||
|
- Rely on argument order for default key generation
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Memory Usage
|
||||||
|
|
||||||
|
- `InFlight`: Minimal memory overhead (one Promise per instance)
|
||||||
|
- `InFlightWithKey`: Memory usage scales with unique parameter combinations
|
||||||
|
- `InFlightWithCache`: Additional memory for cached results
|
||||||
|
|
||||||
|
### Cleanup
|
||||||
|
|
||||||
|
- In-flight requests are automatically cleaned up on completion
|
||||||
|
- Cache entries are cleaned up on expiry
|
||||||
|
- WeakMap ensures instances can be garbage collected
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The decorators are fully tested with comprehensive unit tests. Key test scenarios include:
|
||||||
|
|
||||||
|
- Multiple simultaneous calls deduplication
|
||||||
|
- Error handling and cleanup
|
||||||
|
- Cache expiration
|
||||||
|
- Instance isolation
|
||||||
|
- Key generation
|
||||||
|
|
||||||
|
Run tests with:
|
||||||
|
```bash
|
||||||
|
npx nx test common-decorators
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### From Manual Implementation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before: Manual in-flight tracking
|
||||||
|
class MyService {
|
||||||
|
private inFlight: Promise<Data> | null = null;
|
||||||
|
|
||||||
|
async fetchData(): Promise<Data> {
|
||||||
|
if (this.inFlight) {
|
||||||
|
return this.inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inFlight = this.doFetch();
|
||||||
|
try {
|
||||||
|
return await this.inFlight;
|
||||||
|
} finally {
|
||||||
|
this.inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After: Using decorator
|
||||||
|
class MyService {
|
||||||
|
@InFlight()
|
||||||
|
async fetchData(): Promise<Data> {
|
||||||
|
return await this.doFetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### From RxJS shareReplay
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before: RxJS approach
|
||||||
|
class MyService {
|
||||||
|
private data$ = this.http.get<Data>('/api/data').pipe(
|
||||||
|
shareReplay({ bufferSize: 1, refCount: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
getData(): Observable<Data> {
|
||||||
|
return this.data$;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After: Promise-based with decorator
|
||||||
|
class MyService {
|
||||||
|
@InFlightWithCache({ cacheTime: 5 * 60 * 1000 })
|
||||||
|
async getData(): Promise<Data> {
|
||||||
|
return await this.http.get<Data>('/api/data').toPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding new decorators:
|
||||||
|
1. Add implementation in `src/lib/`
|
||||||
|
2. Include comprehensive unit tests
|
||||||
|
3. Update this documentation
|
||||||
|
4. Export from `src/index.ts`
|
||||||
34
libs/common/decorators/eslint.config.cjs
Normal file
34
libs/common/decorators/eslint.config.cjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const nx = require('@nx/eslint-plugin');
|
||||||
|
const baseConfig = require('../../../eslint.config.js');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
...baseConfig,
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'common',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'common',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
20
libs/common/decorators/project.json
Normal file
20
libs/common/decorators/project.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "common-decorators",
|
||||||
|
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "libs/common/decorators/src",
|
||||||
|
"prefix": "common",
|
||||||
|
"projectType": "library",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {
|
||||||
|
"test": {
|
||||||
|
"executor": "@nx/vite:test",
|
||||||
|
"outputs": ["{options.reportsDirectory}"],
|
||||||
|
"options": {
|
||||||
|
"reportsDirectory": "../../../coverage/libs/common/decorators"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
libs/common/decorators/src/index.ts
Normal file
1
libs/common/decorators/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './lib/in-flight.decorator';
|
||||||
321
libs/common/decorators/src/lib/in-flight.decorator.spec.ts
Normal file
321
libs/common/decorators/src/lib/in-flight.decorator.spec.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { InFlight, InFlightWithKey, InFlightWithCache } from './in-flight.decorator';
|
||||||
|
|
||||||
|
describe('InFlight Decorators', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InFlight', () => {
|
||||||
|
class TestService {
|
||||||
|
callCount = 0;
|
||||||
|
|
||||||
|
@InFlight()
|
||||||
|
async fetchData(delay = 100): Promise<string> {
|
||||||
|
this.callCount++;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
return `result-${this.callCount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@InFlight()
|
||||||
|
async fetchWithError(delay = 100): Promise<string> {
|
||||||
|
this.callCount++;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
throw new Error('Test error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should prevent multiple simultaneous calls', async () => {
|
||||||
|
const service = new TestService();
|
||||||
|
|
||||||
|
// Make three simultaneous calls
|
||||||
|
const promise1 = service.fetchData();
|
||||||
|
const promise2 = service.fetchData();
|
||||||
|
const promise3 = service.fetchData();
|
||||||
|
|
||||||
|
// Advance timers to complete the async operation
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
// All promises should resolve to the same value
|
||||||
|
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||||
|
|
||||||
|
expect(result1).toBe('result-1');
|
||||||
|
expect(result2).toBe('result-1');
|
||||||
|
expect(result3).toBe('result-1');
|
||||||
|
expect(service.callCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow subsequent calls after completion', async () => {
|
||||||
|
const service = new TestService();
|
||||||
|
|
||||||
|
// First call
|
||||||
|
const promise1 = service.fetchData();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result1 = await promise1;
|
||||||
|
expect(result1).toBe('result-1');
|
||||||
|
|
||||||
|
// Second call after first completes
|
||||||
|
const promise2 = service.fetchData();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result2 = await promise2;
|
||||||
|
expect(result2).toBe('result-2');
|
||||||
|
|
||||||
|
expect(service.callCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors properly', async () => {
|
||||||
|
const service = new TestService();
|
||||||
|
|
||||||
|
// Make multiple calls that will error
|
||||||
|
const promise1 = service.fetchWithError();
|
||||||
|
const promise2 = service.fetchWithError();
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
// Both should reject with the same error
|
||||||
|
await expect(promise1).rejects.toThrow('Test error');
|
||||||
|
await expect(promise2).rejects.toThrow('Test error');
|
||||||
|
expect(service.callCount).toBe(1);
|
||||||
|
|
||||||
|
// Should allow new call after error
|
||||||
|
const promise3 = service.fetchWithError();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await expect(promise3).rejects.toThrow('Test error');
|
||||||
|
expect(service.callCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain separate state per instance', async () => {
|
||||||
|
const service1 = new TestService();
|
||||||
|
const service2 = new TestService();
|
||||||
|
|
||||||
|
// Make simultaneous calls on different instances
|
||||||
|
const promise1 = service1.fetchData();
|
||||||
|
const promise2 = service2.fetchData();
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||||
|
|
||||||
|
// Each instance should have made its own call
|
||||||
|
expect(result1).toBe('result-1');
|
||||||
|
expect(result2).toBe('result-1');
|
||||||
|
expect(service1.callCount).toBe(1);
|
||||||
|
expect(service2.callCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InFlightWithKey', () => {
|
||||||
|
class UserService {
|
||||||
|
callCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
@InFlightWithKey({
|
||||||
|
keyGenerator: (userId: string) => userId
|
||||||
|
})
|
||||||
|
async fetchUser(userId: string, delay = 100): Promise<{ id: string; name: string }> {
|
||||||
|
const count = (this.callCounts.get(userId) || 0) + 1;
|
||||||
|
this.callCounts.set(userId, count);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
return { id: userId, name: `User ${userId} - Call ${count}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
@InFlightWithKey()
|
||||||
|
async fetchWithDefaultKey(param1: string, param2: number): Promise<string> {
|
||||||
|
const key = `${param1}-${param2}`;
|
||||||
|
const count = (this.callCounts.get(key) || 0) + 1;
|
||||||
|
this.callCounts.set(key, count);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return `Result ${count}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should deduplicate calls with same key', async () => {
|
||||||
|
const service = new UserService();
|
||||||
|
|
||||||
|
// Multiple calls with same userId
|
||||||
|
const promise1 = service.fetchUser('user1');
|
||||||
|
const promise2 = service.fetchUser('user1');
|
||||||
|
const promise3 = service.fetchUser('user1');
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||||
|
|
||||||
|
expect(result1).toEqual({ id: 'user1', name: 'User user1 - Call 1' });
|
||||||
|
expect(result2).toEqual(result1);
|
||||||
|
expect(result3).toEqual(result1);
|
||||||
|
expect(service.callCounts.get('user1')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow simultaneous calls with different keys', async () => {
|
||||||
|
const service = new UserService();
|
||||||
|
|
||||||
|
// Calls with different userIds
|
||||||
|
const promise1 = service.fetchUser('user1');
|
||||||
|
const promise2 = service.fetchUser('user2');
|
||||||
|
const promise3 = service.fetchUser('user1'); // Duplicate of first
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||||
|
|
||||||
|
expect(result1).toEqual({ id: 'user1', name: 'User user1 - Call 1' });
|
||||||
|
expect(result2).toEqual({ id: 'user2', name: 'User user2 - Call 1' });
|
||||||
|
expect(result3).toEqual(result1); // Same as first call
|
||||||
|
|
||||||
|
expect(service.callCounts.get('user1')).toBe(1);
|
||||||
|
expect(service.callCounts.get('user2')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use JSON.stringify as default key generator', async () => {
|
||||||
|
const service = new UserService();
|
||||||
|
|
||||||
|
// Multiple calls with same arguments
|
||||||
|
const promise1 = service.fetchWithDefaultKey('test', 123);
|
||||||
|
const promise2 = service.fetchWithDefaultKey('test', 123);
|
||||||
|
|
||||||
|
// Different arguments
|
||||||
|
const promise3 = service.fetchWithDefaultKey('test', 456);
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||||
|
|
||||||
|
expect(result1).toBe('Result 1');
|
||||||
|
expect(result2).toBe('Result 1'); // Same as first
|
||||||
|
expect(result3).toBe('Result 1'); // Different key, separate call
|
||||||
|
|
||||||
|
expect(service.callCounts.get('test-123')).toBe(1);
|
||||||
|
expect(service.callCounts.get('test-456')).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InFlightWithCache', () => {
|
||||||
|
class DataService {
|
||||||
|
callCount = 0;
|
||||||
|
|
||||||
|
@InFlightWithCache({
|
||||||
|
cacheTime: 1000, // 1 second cache
|
||||||
|
keyGenerator: (query: string) => query
|
||||||
|
})
|
||||||
|
async search(query: string): Promise<string[]> {
|
||||||
|
this.callCount++;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return [`result-${query}-${this.callCount}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
@InFlightWithCache({
|
||||||
|
cacheTime: 500
|
||||||
|
})
|
||||||
|
async fetchWithExpiry(id: number): Promise<string> {
|
||||||
|
this.callCount++;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
return `data-${id}-${this.callCount}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should cache results for specified time', async () => {
|
||||||
|
const service = new DataService();
|
||||||
|
|
||||||
|
// First call
|
||||||
|
const promise1 = service.search('test');
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result1 = await promise1;
|
||||||
|
expect(result1).toEqual(['result-test-1']);
|
||||||
|
expect(service.callCount).toBe(1);
|
||||||
|
|
||||||
|
// Second call within cache time - should return cached result
|
||||||
|
const result2 = await service.search('test');
|
||||||
|
expect(result2).toEqual(['result-test-1']);
|
||||||
|
expect(service.callCount).toBe(1); // No new call
|
||||||
|
|
||||||
|
// Advance time past cache expiry
|
||||||
|
vi.advanceTimersByTime(1100);
|
||||||
|
|
||||||
|
// Third call after cache expiry - should make new call
|
||||||
|
const promise3 = service.search('test');
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result3 = await promise3;
|
||||||
|
expect(result3).toEqual(['result-test-2']);
|
||||||
|
expect(service.callCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle in-flight deduplication with caching', async () => {
|
||||||
|
const service = new DataService();
|
||||||
|
|
||||||
|
// Multiple simultaneous calls
|
||||||
|
const promise1 = service.search('query1');
|
||||||
|
const promise2 = service.search('query1');
|
||||||
|
const promise3 = service.search('query1');
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||||
|
|
||||||
|
// All should get same result
|
||||||
|
expect(result1).toEqual(['result-query1-1']);
|
||||||
|
expect(result2).toEqual(result1);
|
||||||
|
expect(result3).toEqual(result1);
|
||||||
|
expect(service.callCount).toBe(1);
|
||||||
|
|
||||||
|
// Subsequent call should use cache
|
||||||
|
const result4 = await service.search('query1');
|
||||||
|
expect(result4).toEqual(['result-query1-1']);
|
||||||
|
expect(service.callCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up expired cache entries', async () => {
|
||||||
|
const service = new DataService();
|
||||||
|
|
||||||
|
// Make a call
|
||||||
|
const promise1 = service.fetchWithExpiry(1);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise1;
|
||||||
|
|
||||||
|
// Advance time past cache expiry
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
|
||||||
|
// Make another call - should not use expired cache
|
||||||
|
service.callCount = 0; // Reset for clarity
|
||||||
|
const promise2 = service.fetchWithExpiry(1);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result2 = await promise2;
|
||||||
|
|
||||||
|
expect(result2).toBe('data-1-1');
|
||||||
|
expect(service.callCount).toBe(1); // New call was made
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors without caching them', async () => {
|
||||||
|
class ErrorService {
|
||||||
|
callCount = 0;
|
||||||
|
|
||||||
|
@InFlightWithCache({ cacheTime: 1000 })
|
||||||
|
async fetchWithError(): Promise<string> {
|
||||||
|
this.callCount++;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
throw new Error('API Error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = new ErrorService();
|
||||||
|
|
||||||
|
// First call that errors
|
||||||
|
const promise1 = service.fetchWithError();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await expect(promise1).rejects.toThrow('API Error');
|
||||||
|
expect(service.callCount).toBe(1);
|
||||||
|
|
||||||
|
// Second call should not use cache (errors aren't cached)
|
||||||
|
const promise2 = service.fetchWithError();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await expect(promise2).rejects.toThrow('API Error');
|
||||||
|
expect(service.callCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
251
libs/common/decorators/src/lib/in-flight.decorator.ts
Normal file
251
libs/common/decorators/src/lib/in-flight.decorator.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
/**
|
||||||
|
* Decorator that prevents multiple simultaneous calls to the same async method.
|
||||||
|
* All concurrent calls will receive the same Promise result.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* class MyService {
|
||||||
|
* @InFlight()
|
||||||
|
* async fetchData(): Promise<Data> {
|
||||||
|
* // This method will only execute once even if called multiple times simultaneously
|
||||||
|
* return await api.getData();
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function InFlight<
|
||||||
|
T extends (...args: any[]) => Promise<any>,
|
||||||
|
>(): MethodDecorator {
|
||||||
|
const inFlightMap = new WeakMap<object, Promise<any>>();
|
||||||
|
|
||||||
|
return function (
|
||||||
|
target: any,
|
||||||
|
propertyKey: string | symbol,
|
||||||
|
descriptor: PropertyDescriptor,
|
||||||
|
): PropertyDescriptor {
|
||||||
|
const originalMethod = descriptor.value;
|
||||||
|
|
||||||
|
descriptor.value = async function (
|
||||||
|
this: any,
|
||||||
|
...args: Parameters<T>
|
||||||
|
): Promise<ReturnType<T>> {
|
||||||
|
// Check if there's already an in-flight request for this instance
|
||||||
|
const existingRequest = inFlightMap.get(this);
|
||||||
|
if (existingRequest) {
|
||||||
|
return existingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new request and store it
|
||||||
|
const promise = originalMethod
|
||||||
|
.apply(this, args)
|
||||||
|
.then((result: any) => {
|
||||||
|
// Clean up after successful completion
|
||||||
|
inFlightMap.delete(this);
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
// Clean up after error
|
||||||
|
inFlightMap.delete(this);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
inFlightMap.set(this, promise);
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator that prevents multiple simultaneous calls to the same async method
|
||||||
|
* while considering method arguments. Each unique set of arguments gets its own
|
||||||
|
* in-flight tracking.
|
||||||
|
*
|
||||||
|
* @param options Configuration options for the decorator
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* class UserService {
|
||||||
|
* @InFlightWithKey({
|
||||||
|
* keyGenerator: (userId: string) => userId
|
||||||
|
* })
|
||||||
|
* async fetchUser(userId: string): Promise<User> {
|
||||||
|
* // Calls with different userIds can execute simultaneously
|
||||||
|
* // Calls with the same userId will share the same promise
|
||||||
|
* return await api.getUser(userId);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface InFlightWithKeyOptions<T extends (...args: any[]) => any> {
|
||||||
|
/**
|
||||||
|
* Generate a cache key from the method arguments.
|
||||||
|
* If not provided, uses JSON.stringify on all arguments.
|
||||||
|
*/
|
||||||
|
keyGenerator?: (...args: Parameters<T>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InFlightWithKey<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
options: InFlightWithKeyOptions<T> = {},
|
||||||
|
): MethodDecorator {
|
||||||
|
const inFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
|
||||||
|
|
||||||
|
return function (
|
||||||
|
target: any,
|
||||||
|
propertyKey: string | symbol,
|
||||||
|
descriptor: PropertyDescriptor,
|
||||||
|
): PropertyDescriptor {
|
||||||
|
const originalMethod = descriptor.value;
|
||||||
|
|
||||||
|
descriptor.value = async function (
|
||||||
|
this: any,
|
||||||
|
...args: Parameters<T>
|
||||||
|
): Promise<ReturnType<T>> {
|
||||||
|
// Initialize map for this instance if needed
|
||||||
|
if (!inFlightMap.has(this)) {
|
||||||
|
inFlightMap.set(this, new Map());
|
||||||
|
}
|
||||||
|
const instanceMap = inFlightMap.get(this)!;
|
||||||
|
|
||||||
|
// Generate cache key
|
||||||
|
const key = options.keyGenerator
|
||||||
|
? options.keyGenerator(...args)
|
||||||
|
: JSON.stringify(args);
|
||||||
|
|
||||||
|
// Check if there's already an in-flight request for this key
|
||||||
|
const existingRequest = instanceMap.get(key);
|
||||||
|
if (existingRequest) {
|
||||||
|
return existingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new request and store it
|
||||||
|
const promise = originalMethod
|
||||||
|
.apply(this, args)
|
||||||
|
.then((result: any) => {
|
||||||
|
// Clean up after successful completion
|
||||||
|
instanceMap.delete(key);
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
// Clean up after error
|
||||||
|
instanceMap.delete(key);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
instanceMap.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator that prevents multiple simultaneous calls to the same async method
|
||||||
|
* with additional caching capabilities.
|
||||||
|
*
|
||||||
|
* @param options Configuration options for the decorator
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* class DataService {
|
||||||
|
* @InFlightWithCache({
|
||||||
|
* cacheTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
* keyGenerator: (params: QueryParams) => params.query
|
||||||
|
* })
|
||||||
|
* async searchData(params: QueryParams): Promise<SearchResult> {
|
||||||
|
* return await api.search(params);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface InFlightWithCacheOptions<T extends (...args: any[]) => any> {
|
||||||
|
/**
|
||||||
|
* Generate a cache key from the method arguments.
|
||||||
|
* If not provided, uses JSON.stringify on all arguments.
|
||||||
|
*/
|
||||||
|
keyGenerator?: (...args: Parameters<T>) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time in milliseconds to keep the result cached after completion.
|
||||||
|
* If not provided, result is not cached after completion.
|
||||||
|
*/
|
||||||
|
cacheTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InFlightWithCache<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
options: InFlightWithCacheOptions<T> = {},
|
||||||
|
): MethodDecorator {
|
||||||
|
const inFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
|
||||||
|
const cacheMap = new WeakMap<
|
||||||
|
object,
|
||||||
|
Map<string, { result: any; expiry: number }>
|
||||||
|
>();
|
||||||
|
|
||||||
|
return function (
|
||||||
|
target: any,
|
||||||
|
propertyKey: string | symbol,
|
||||||
|
descriptor: PropertyDescriptor,
|
||||||
|
): PropertyDescriptor {
|
||||||
|
const originalMethod = descriptor.value;
|
||||||
|
|
||||||
|
descriptor.value = async function (
|
||||||
|
this: any,
|
||||||
|
...args: Parameters<T>
|
||||||
|
): Promise<ReturnType<T>> {
|
||||||
|
// Initialize maps for this instance if needed
|
||||||
|
if (!inFlightMap.has(this)) {
|
||||||
|
inFlightMap.set(this, new Map());
|
||||||
|
cacheMap.set(this, new Map());
|
||||||
|
}
|
||||||
|
const instanceInFlight = inFlightMap.get(this)!;
|
||||||
|
const instanceCache = cacheMap.get(this)!;
|
||||||
|
|
||||||
|
// Generate cache key
|
||||||
|
const key = options.keyGenerator
|
||||||
|
? options.keyGenerator(...args)
|
||||||
|
: JSON.stringify(args);
|
||||||
|
|
||||||
|
// Check cache first (if cacheTime is set)
|
||||||
|
if (options.cacheTime) {
|
||||||
|
const cached = instanceCache.get(key);
|
||||||
|
if (cached && cached.expiry > Date.now()) {
|
||||||
|
return Promise.resolve(cached.result);
|
||||||
|
}
|
||||||
|
// Clean up expired cache entry
|
||||||
|
if (cached) {
|
||||||
|
instanceCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's already an in-flight request
|
||||||
|
const existingRequest = instanceInFlight.get(key);
|
||||||
|
if (existingRequest) {
|
||||||
|
return existingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new request
|
||||||
|
const promise = originalMethod
|
||||||
|
.apply(this, args)
|
||||||
|
.then((result: any) => {
|
||||||
|
// Cache result if cacheTime is set
|
||||||
|
if (options.cacheTime) {
|
||||||
|
instanceCache.set(key, {
|
||||||
|
result,
|
||||||
|
expiry: Date.now() + options.cacheTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Always clean up in-flight request
|
||||||
|
instanceInFlight.delete(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
instanceInFlight.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
}
|
||||||
13
libs/common/decorators/src/test-setup.ts
Normal file
13
libs/common/decorators/src/test-setup.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import '@analogjs/vitest-angular/setup-zone';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting,
|
||||||
|
} from '@angular/platform-browser/testing';
|
||||||
|
import { getTestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
getTestBed().initTestEnvironment(
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting(),
|
||||||
|
);
|
||||||
30
libs/common/decorators/tsconfig.json
Normal file
30
libs/common/decorators/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"importHelpers": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"typeCheckHostBindings": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.lib.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
27
libs/common/decorators/tsconfig.lib.json
Normal file
27
libs/common/decorators/tsconfig.lib.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../dist/out-tsc",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/test-setup.ts",
|
||||||
|
"jest.config.ts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx"
|
||||||
|
],
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
29
libs/common/decorators/tsconfig.spec.json
Normal file
29
libs/common/decorators/tsconfig.spec.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../dist/out-tsc",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals",
|
||||||
|
"vitest/importMeta",
|
||||||
|
"vite/client",
|
||||||
|
"node",
|
||||||
|
"vitest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
],
|
||||||
|
"files": ["src/test-setup.ts"]
|
||||||
|
}
|
||||||
27
libs/common/decorators/vite.config.mts
Normal file
27
libs/common/decorators/vite.config.mts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference types='vitest' />
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import angular from '@analogjs/vite-plugin-angular';
|
||||||
|
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||||
|
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
root: __dirname,
|
||||||
|
cacheDir: '../../../node_modules/.vite/libs/common/decorators',
|
||||||
|
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
|
// Uncomment this if you are using workers.
|
||||||
|
// worker: {
|
||||||
|
// plugins: [ nxViteTsPaths() ],
|
||||||
|
// },
|
||||||
|
test: {
|
||||||
|
watch: false,
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
setupFiles: ['src/test-setup.ts'],
|
||||||
|
reporters: ['default'],
|
||||||
|
coverage: {
|
||||||
|
reportsDirectory: '../../../coverage/libs/common/decorators',
|
||||||
|
provider: 'v8' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './lib/services';
|
export * from './lib/services';
|
||||||
export * from './lib/models';
|
export * from './lib/schemas';
|
||||||
|
export * from './lib/models';
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
export const ASSIGNED_STOCK_STORAGE_KEY =
|
export const SUPPLIER_STORAGE_KEY = '48872c78-ad7f-455d-b775-07b00920f80d';
|
||||||
'd8a11dd9-1f32-4646-881d-6ec856cbe9d0';
|
|
||||||
|
|
||||||
export const SUPPLIER_STORAGE_KEY = '48872c78-ad7f-455d-b775-07b00920f80d';
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
export * from './remission-list-category';
|
export * from './key-value-string-and-string';
|
||||||
export * from './price-value';
|
export * from './price-value';
|
||||||
export * from './price';
|
export * from './price';
|
||||||
export * from './product';
|
export * from './product';
|
||||||
export * from './return-item';
|
export * from './query-settings';
|
||||||
export * from './stock';
|
export * from './receipt-item';
|
||||||
export * from './stock-info';
|
export * from './receipt';
|
||||||
export * from './supplier';
|
export * from './remission-list-category';
|
||||||
export * from './query-settings';
|
export * from './return-item';
|
||||||
export * from './key-value-string-and-string';
|
export * from './return-suggestion';
|
||||||
export * from './return-suggestion';
|
export * from './return';
|
||||||
|
export * from './stock-info';
|
||||||
|
export * from './stock';
|
||||||
|
export * from './supplier';
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { KeyValueDTOOfStringAndString } from '@generated/swagger/inventory-api';
|
import { KeyValueDTOOfStringAndString } from '@generated/swagger/inventory-api';
|
||||||
|
|
||||||
export interface KeyValueStringAndString extends KeyValueDTOOfStringAndString {}
|
export type KeyValueStringAndString = KeyValueDTOOfStringAndString;
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { QuerySettingsDTO } from '@generated/swagger/inventory-api';
|
import { QuerySettingsDTO } from '@generated/swagger/inventory-api';
|
||||||
|
|
||||||
export interface QuerySettings extends QuerySettingsDTO {}
|
export type QuerySettings = QuerySettingsDTO;
|
||||||
|
|||||||
35
libs/remission/data-access/src/lib/models/receipt-item.ts
Normal file
35
libs/remission/data-access/src/lib/models/receipt-item.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { ReceiptItemDTO } from '@generated/swagger/inventory-api';
|
||||||
|
import { Product } from './product';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an individual item within a remission return receipt.
|
||||||
|
* Extends the base ReceiptItemDTO with additional product information.
|
||||||
|
*
|
||||||
|
* @interface ReceiptItem
|
||||||
|
* @extends {ReceiptItemDTO}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const receiptItem: ReceiptItem = {
|
||||||
|
* id: 123,
|
||||||
|
* product: {
|
||||||
|
* id: 456,
|
||||||
|
* name: 'Sample Product',
|
||||||
|
* // ... other product properties
|
||||||
|
* },
|
||||||
|
* // ... other ReceiptItemDTO properties
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export interface ReceiptItem extends ReceiptItemDTO {
|
||||||
|
/**
|
||||||
|
* Unique identifier for the receipt item.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The product associated with this receipt item.
|
||||||
|
* Contains detailed product information including name, SKU, and other attributes.
|
||||||
|
* @type {Product}
|
||||||
|
*/
|
||||||
|
product: Product;
|
||||||
|
}
|
||||||
44
libs/remission/data-access/src/lib/models/receipt.ts
Normal file
44
libs/remission/data-access/src/lib/models/receipt.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { ReceiptDTO } from '@generated/swagger/inventory-api';
|
||||||
|
import { EntityContainer } from '@isa/common/data-access';
|
||||||
|
import { ReceiptItem } from './receipt-item';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a remission return receipt containing multiple receipt items.
|
||||||
|
* Extends the base ReceiptDTO with additional properties for local processing.
|
||||||
|
*
|
||||||
|
* @interface Receipt
|
||||||
|
* @extends {ReceiptDTO}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const receipt: Receipt = {
|
||||||
|
* id: 1001,
|
||||||
|
* receiptNumber: 'RR-2024-001',
|
||||||
|
* items: [
|
||||||
|
* { id: 123, data: receiptItem1 }, // When eager loading is active
|
||||||
|
* { id: 124, data: undefined } // When only ID is available
|
||||||
|
* ],
|
||||||
|
* // ... other ReceiptDTO properties
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export interface Receipt extends ReceiptDTO {
|
||||||
|
/**
|
||||||
|
* Unique identifier for the receipt.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The receipt number used for tracking and reference.
|
||||||
|
* Typically follows a standardized format (e.g., 'RR-YYYY-###').
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
receiptNumber: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection of receipt items wrapped in EntityContainer.
|
||||||
|
* Each container holds an ID and optionally the resolved ReceiptItem data
|
||||||
|
* when eager loading is active.
|
||||||
|
* @type {EntityContainer<ReceiptItem>[]}
|
||||||
|
*/
|
||||||
|
items: EntityContainer<ReceiptItem>[];
|
||||||
|
}
|
||||||
36
libs/remission/data-access/src/lib/models/return.ts
Normal file
36
libs/remission/data-access/src/lib/models/return.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { ReturnDTO } from '@generated/swagger/inventory-api';
|
||||||
|
import { EntityContainer } from '@isa/common/data-access';
|
||||||
|
import { Receipt } from './receipt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a remission return containing multiple receipts.
|
||||||
|
* A return groups related receipts together for processing and tracking.
|
||||||
|
*
|
||||||
|
* @interface Return
|
||||||
|
* @extends {ReturnDTO}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const returnEntity: Return = {
|
||||||
|
* id: 5001,
|
||||||
|
* receipts: [
|
||||||
|
* { id: 101, data: receipt1 }, // When eager loading is active
|
||||||
|
* { id: 102, data: undefined } // When only ID is available
|
||||||
|
* ],
|
||||||
|
* // ... other ReturnDTO properties like status, date, supplier info
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export interface Return extends ReturnDTO {
|
||||||
|
/**
|
||||||
|
* Unique identifier for the return.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection of receipts associated with this return.
|
||||||
|
* Each container holds an ID and optionally the resolved Receipt data
|
||||||
|
* when eager loading is active.
|
||||||
|
* @type {EntityContainer<Receipt>[]}
|
||||||
|
*/
|
||||||
|
receipts: EntityContainer<Receipt>[];
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { StockInfoDTO } from '@generated/swagger/inventory-api';
|
import { StockInfoDTO } from '@generated/swagger/inventory-api';
|
||||||
|
|
||||||
export interface StockInfo extends StockInfoDTO {}
|
export type StockInfo = StockInfoDTO;
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
import { StockDTO } from '@generated/swagger/inventory-api';
|
import { StockDTO } from '@generated/swagger/inventory-api';
|
||||||
|
|
||||||
export interface Stock extends StockDTO {}
|
/**
|
||||||
|
* Represents stock information for products in the remission system.
|
||||||
|
* Extends the base StockDTO with a unique identifier.
|
||||||
|
*
|
||||||
|
* @interface Stock
|
||||||
|
* @extends {StockDTO}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const stock: Stock = {
|
||||||
|
* id: 101,
|
||||||
|
* quantity: 150,
|
||||||
|
* availableQuantity: 120,
|
||||||
|
* reservedQuantity: 30,
|
||||||
|
* // ... other StockDTO properties like location, warehouse info
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export interface Stock extends StockDTO {
|
||||||
|
/**
|
||||||
|
* Unique identifier for the stock entry.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { SupplierDTO } from '@generated/swagger/inventory-api';
|
import { SupplierDTO } from '@generated/swagger/inventory-api';
|
||||||
|
|
||||||
export interface Supplier extends SupplierDTO {}
|
export type Supplier = SupplierDTO;
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const FetchProductGroupsSchema = z.object({
|
|
||||||
assignedStockId: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FetchProductGroups = z.infer<typeof FetchProductGroupsSchema>;
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod schema for validating remission return receipt fetch parameters.
|
||||||
|
* Ensures both receiptId and returnId are valid numbers.
|
||||||
|
*
|
||||||
|
* @constant
|
||||||
|
* @type {z.ZodObject}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const params = FetchRemissionReturnReceiptSchema.parse({
|
||||||
|
* receiptId: '123',
|
||||||
|
* returnId: '456'
|
||||||
|
* });
|
||||||
|
* // Result: { receiptId: 123, returnId: 456 }
|
||||||
|
*/
|
||||||
|
export const FetchRemissionReturnReceiptSchema = z.object({
|
||||||
|
/**
|
||||||
|
* The receipt identifier - coerced to number for flexibility.
|
||||||
|
*/
|
||||||
|
receiptId: z.coerce.number(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The return identifier - coerced to number for flexibility.
|
||||||
|
*/
|
||||||
|
returnId: z.coerce.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type representing the parsed output of FetchRemissionReturnReceiptSchema.
|
||||||
|
* Contains validated and coerced receiptId and returnId as numbers.
|
||||||
|
*
|
||||||
|
* @typedef {Object} FetchRemissionReturnReceipt
|
||||||
|
* @property {number} receiptId - The validated receipt identifier
|
||||||
|
* @property {number} returnId - The validated return identifier
|
||||||
|
*/
|
||||||
|
export type FetchRemissionReturnReceipt = z.infer<
|
||||||
|
typeof FetchRemissionReturnReceiptSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type representing the input parameters for FetchRemissionReturnReceiptSchema.
|
||||||
|
* Accepts string or number values that can be coerced to numbers.
|
||||||
|
*
|
||||||
|
* @typedef {Object} FetchRemissionReturnParams
|
||||||
|
* @property {string | number} receiptId - The receipt identifier (can be string or number)
|
||||||
|
* @property {string | number} returnId - The return identifier (can be string or number)
|
||||||
|
*/
|
||||||
|
export type FetchRemissionReturnParams = z.input<
|
||||||
|
typeof FetchRemissionReturnReceiptSchema
|
||||||
|
>;
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const FetchSuppliersSchema = z.object({
|
|
||||||
assignedStockId: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FetchSuppliers = z.infer<typeof FetchSuppliersSchema>;
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from './fetch-product-groups.schema';
|
export * from './fetch-query-settings.schema';
|
||||||
export * from './fetch-query-settings.schema';
|
export * from './fetch-remission-return-receipt.schema';
|
||||||
export * from './fetch-return-reason.schema';
|
export * from './fetch-return-reason.schema';
|
||||||
export * from './fetch-stock-in-stock.schema';
|
export * from './fetch-stock-in-stock.schema';
|
||||||
export * from './fetch-suppliers.schema';
|
export * from './query-token.schema';
|
||||||
export * from './query-token.schema';
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './remission-product-group.service';
|
export * from './remission-product-group.service';
|
||||||
export * from './remission-reason.service';
|
export * from './remission-reason.service';
|
||||||
export * from './remission-search.service';
|
export * from './remission-return-receipt.service';
|
||||||
export * from './remission-stock.service';
|
export * from './remission-search.service';
|
||||||
export * from './remission-supplier.service';
|
export * from './remission-stock.service';
|
||||||
|
export * from './remission-supplier.service';
|
||||||
|
|||||||
@@ -1,28 +1,94 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { RemiService } from '@generated/swagger/inventory-api';
|
import { RemiService } from '@generated/swagger/inventory-api';
|
||||||
import { FetchProductGroups, FetchProductGroupsSchema } from '../schemas';
|
import { KeyValueStringAndString } from '../models';
|
||||||
import { KeyValueStringAndString } from '../models';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { logger } from '@isa/core/logging';
|
||||||
|
import { RemissionStockService } from './remission-stock.service';
|
||||||
@Injectable({ providedIn: 'root' })
|
import { injectStorage, MemoryStorageProvider } from '@isa/core/storage';
|
||||||
export class RemissionProductGroupService {
|
import {
|
||||||
#remiService = inject(RemiService);
|
DataAccessError,
|
||||||
|
ResponseArgsError,
|
||||||
async fetchProductGroups(
|
takeUntilAborted,
|
||||||
params: FetchProductGroups,
|
} from '@isa/common/data-access';
|
||||||
): Promise<KeyValueStringAndString[]> {
|
import { InFlightWithCache } from '@isa/common/decorators';
|
||||||
const parsed = FetchProductGroupsSchema.parse(params);
|
|
||||||
|
/**
|
||||||
const req$ = this.#remiService.RemiProductgroups({
|
* Service responsible for managing remission product groups.
|
||||||
stockId: parsed.assignedStockId,
|
* Handles fetching product group data from the remission API.
|
||||||
});
|
*
|
||||||
|
* @class RemissionProductGroupService
|
||||||
const res = await firstValueFrom(req$);
|
* @injectable
|
||||||
|
*
|
||||||
if (res.error || !res.result) {
|
* @example
|
||||||
throw new Error(res.message || 'Failed to fetch Product Groups');
|
* // Inject the service
|
||||||
}
|
* constructor(private productGroupService: RemissionProductGroupService) {}
|
||||||
|
*
|
||||||
return res.result as KeyValueStringAndString[];
|
* // Fetch product groups for a stock
|
||||||
}
|
* const groups = await this.productGroupService.fetchProductGroups({
|
||||||
}
|
* assignedStockId: 'stock123'
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RemissionProductGroupService {
|
||||||
|
#remiService = inject(RemiService);
|
||||||
|
#stockService = inject(RemissionStockService);
|
||||||
|
#logger = logger(() => ({ service: 'RemissionProductGroupService' }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all available product groups for the specified stock.
|
||||||
|
* Validates input parameters using FetchProductGroupsSchema.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {FetchProductGroups} params - Parameters for the product groups query
|
||||||
|
* @param {string} params.assignedStockId - ID of the assigned stock
|
||||||
|
* @returns {Promise<KeyValueStringAndString[]>} Array of key-value pairs representing product groups
|
||||||
|
* @throws {Error} When the API request fails or returns an error
|
||||||
|
* @throws {z.ZodError} When parameter validation fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* try {
|
||||||
|
* const productGroups = await service.fetchProductGroups({
|
||||||
|
* assignedStockId: 'stock123'
|
||||||
|
* });
|
||||||
|
* productGroups.forEach(group => {
|
||||||
|
* console.log(`${group.key}: ${group.value}`);
|
||||||
|
* });
|
||||||
|
* } catch (error) {
|
||||||
|
* console.error('Failed to fetch product groups:', error);
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
@InFlightWithCache()
|
||||||
|
async fetchProductGroups(
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<KeyValueStringAndString[]> {
|
||||||
|
this.#logger.debug('Fetching product groups');
|
||||||
|
|
||||||
|
const assignedStock = await this.#stockService.fetchAssignedStock();
|
||||||
|
|
||||||
|
this.#logger.info('Fetching product groups from API', () => ({
|
||||||
|
stockId: assignedStock.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let req$ = this.#remiService.RemiProductgroups({
|
||||||
|
stockId: assignedStock.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (abortSignal) {
|
||||||
|
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
|
if (res.error || !res.result) {
|
||||||
|
const error = new ResponseArgsError(res);
|
||||||
|
this.#logger.error('Failed to fetch product groups', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.debug('Successfully fetched product groups', () => ({
|
||||||
|
groupCount: res.result?.length || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.result as KeyValueStringAndString[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,29 +4,82 @@ import { FetchReturnReasonParams, FetchReturnReasonSchema } from '../schemas';
|
|||||||
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
|
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { KeyValueStringAndString } from '../models';
|
import { KeyValueStringAndString } from '../models';
|
||||||
|
import { logger } from '@isa/core/logging';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service responsible for managing remission return reasons.
|
||||||
|
* Handles fetching return reason data from the inventory API.
|
||||||
|
*
|
||||||
|
* @class RemissionReasonService
|
||||||
|
* @injectable
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Inject the service
|
||||||
|
* constructor(private reasonService: RemissionReasonService) {}
|
||||||
|
*
|
||||||
|
* // Fetch return reasons for a stock
|
||||||
|
* const reasons = await this.reasonService.fetchReturnReasons({
|
||||||
|
* stockId: 'stock123'
|
||||||
|
* });
|
||||||
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class RemissionReasonService {
|
export class RemissionReasonService {
|
||||||
#returnService = inject(ReturnService);
|
#returnService = inject(ReturnService);
|
||||||
|
#logger = logger(() => ({ service: 'RemissionReasonService' }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all available return reasons for the specified stock.
|
||||||
|
* Validates input parameters using FetchReturnReasonSchema.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {FetchReturnReasonParams} params - Parameters for the return reasons query
|
||||||
|
* @param {string} params.stockId - ID of the stock to fetch reasons for
|
||||||
|
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
|
||||||
|
* @returns {Promise<KeyValueStringAndString[]>} Array of key-value pairs representing return reasons
|
||||||
|
* @throws {ResponseArgsError} When the API request fails
|
||||||
|
* @throws {z.ZodError} When parameter validation fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const controller = new AbortController();
|
||||||
|
* try {
|
||||||
|
* const reasons = await service.fetchReturnReasons(
|
||||||
|
* { stockId: 'stock123' },
|
||||||
|
* controller.signal
|
||||||
|
* );
|
||||||
|
* reasons.forEach(reason => {
|
||||||
|
* console.log(`${reason.key}: ${reason.value}`);
|
||||||
|
* });
|
||||||
|
* } catch (error) {
|
||||||
|
* console.error('Failed to fetch return reasons:', error);
|
||||||
|
* }
|
||||||
|
*/
|
||||||
async fetchReturnReasons(
|
async fetchReturnReasons(
|
||||||
params: FetchReturnReasonParams,
|
params: FetchReturnReasonParams,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<KeyValueStringAndString[]> {
|
): Promise<KeyValueStringAndString[]> {
|
||||||
|
this.#logger.debug('Fetching return reasons', () => ({ params }));
|
||||||
const { stockId } = FetchReturnReasonSchema.parse(params);
|
const { stockId } = FetchReturnReasonSchema.parse(params);
|
||||||
|
|
||||||
|
this.#logger.info('Fetching return reasons from API', () => ({ stockId }));
|
||||||
|
|
||||||
let req$ = this.#returnService.ReturnGetReturnReasons({ stockId });
|
let req$ = this.#returnService.ReturnGetReturnReasons({ stockId });
|
||||||
|
|
||||||
if (abortSignal) {
|
if (abortSignal) {
|
||||||
|
this.#logger.debug('Request configured with abort signal');
|
||||||
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await firstValueFrom(req$);
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
|
this.#logger.error('Failed to fetch return reasons', new Error(res.message || 'Unknown error'));
|
||||||
throw new ResponseArgsError(res);
|
throw new ResponseArgsError(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.#logger.debug('Successfully fetched return reasons', () => ({
|
||||||
|
reasonCount: res.result?.length || 0
|
||||||
|
}));
|
||||||
|
|
||||||
return res.result as KeyValueStringAndString[];
|
return res.result as KeyValueStringAndString[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { RemissionReturnReceiptService } from './remission-return-receipt.service';
|
||||||
|
import { ReturnService } from '@generated/swagger/inventory-api';
|
||||||
|
import { RemissionStockService } from './remission-stock.service';
|
||||||
|
import { ResponseArgsError } from '@isa/common/data-access';
|
||||||
|
import { Return, Stock, Receipt } from '../models';
|
||||||
|
import { subDays } from 'date-fns';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
jest.mock('@generated/swagger/inventory-api', () => ({
|
||||||
|
ReturnService: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./remission-stock.service');
|
||||||
|
|
||||||
|
describe('RemissionReturnReceiptService', () => {
|
||||||
|
let service: RemissionReturnReceiptService;
|
||||||
|
let mockReturnService: {
|
||||||
|
ReturnQueryReturns: jest.Mock;
|
||||||
|
ReturnGetReturnReceipt: jest.Mock;
|
||||||
|
};
|
||||||
|
let mockRemissionStockService: {
|
||||||
|
fetchAssignedStock: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockStock: Stock = {
|
||||||
|
id: 123,
|
||||||
|
name: 'Test Stock',
|
||||||
|
description: 'Test Description',
|
||||||
|
} as Stock;
|
||||||
|
|
||||||
|
const mockReturns: Return[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
data: {
|
||||||
|
id: 101,
|
||||||
|
receiptNumber: 'REC-2024-001',
|
||||||
|
completed: '2024-01-15T10:30:00.000Z',
|
||||||
|
created: '2024-01-15T09:00:00.000Z',
|
||||||
|
items: [],
|
||||||
|
} as Receipt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as Return,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
data: {
|
||||||
|
id: 102,
|
||||||
|
receiptNumber: 'REC-2024-002',
|
||||||
|
completed: undefined,
|
||||||
|
created: '2024-01-16T13:00:00.000Z',
|
||||||
|
items: [],
|
||||||
|
} as Receipt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as Return,
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReturnService = {
|
||||||
|
ReturnQueryReturns: jest.fn(),
|
||||||
|
ReturnGetReturnReceipt: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRemissionStockService = {
|
||||||
|
fetchAssignedStock: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
RemissionReturnReceiptService,
|
||||||
|
{ provide: ReturnService, useValue: mockReturnService },
|
||||||
|
{ provide: RemissionStockService, useValue: mockRemissionStockService },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(RemissionReturnReceiptService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchCompletedRemissionReturnReceipts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRemissionStockService.fetchAssignedStock.mockResolvedValue(mockStock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch completed return receipts successfully', async () => {
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
of({ result: mockReturns, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.fetchCompletedRemissionReturnReceipts();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockReturns);
|
||||||
|
expect(mockRemissionStockService.fetchAssignedStock).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(mockReturnService.ReturnQueryReturns).toHaveBeenCalledWith({
|
||||||
|
stockId: 123,
|
||||||
|
queryToken: {
|
||||||
|
input: { returncompleted: 'true' },
|
||||||
|
start: expect.any(String),
|
||||||
|
eagerLoading: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use correct date range (7 days ago)', async () => {
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
of({ result: mockReturns, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.fetchCompletedRemissionReturnReceipts();
|
||||||
|
|
||||||
|
const callArgs = mockReturnService.ReturnQueryReturns.mock.calls[0][0];
|
||||||
|
const startDate = new Date(callArgs.queryToken.start);
|
||||||
|
const expectedDate = subDays(new Date(), 7);
|
||||||
|
|
||||||
|
// Check that dates are within 1 second of each other (to handle timing differences)
|
||||||
|
expect(
|
||||||
|
Math.abs(startDate.getTime() - expectedDate.getTime()),
|
||||||
|
).toBeLessThan(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle abort signal', async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
of({ result: mockReturns, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.fetchCompletedRemissionReturnReceipts(
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRemissionStockService.fetchAssignedStock).toHaveBeenCalledWith(
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
expect(mockReturnService.ReturnQueryReturns).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ResponseArgsError when API returns error', async () => {
|
||||||
|
const errorResponse = { error: 'API Error', result: null };
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(of(errorResponse));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.fetchCompletedRemissionReturnReceipts(),
|
||||||
|
).rejects.toThrow(ResponseArgsError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when result is null', async () => {
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
of({ result: null, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.fetchCompletedRemissionReturnReceipts();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when result is undefined', async () => {
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(of({ error: null }));
|
||||||
|
|
||||||
|
const result = await service.fetchCompletedRemissionReturnReceipts();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle stock service errors', async () => {
|
||||||
|
mockRemissionStockService.fetchAssignedStock.mockRejectedValue(
|
||||||
|
new Error('Stock error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.fetchCompletedRemissionReturnReceipts(),
|
||||||
|
).rejects.toThrow('Stock error');
|
||||||
|
expect(mockReturnService.ReturnQueryReturns).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchIncompletedRemissionReturnReceipts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRemissionStockService.fetchAssignedStock.mockResolvedValue(mockStock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch incompleted return receipts successfully', async () => {
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
of({ result: mockReturns, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.fetchIncompletedRemissionReturnReceipts();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockReturns);
|
||||||
|
expect(mockRemissionStockService.fetchAssignedStock).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(mockReturnService.ReturnQueryReturns).toHaveBeenCalledWith({
|
||||||
|
stockId: 123,
|
||||||
|
queryToken: {
|
||||||
|
input: { returncompleted: 'false' },
|
||||||
|
start: expect.any(String),
|
||||||
|
eagerLoading: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use correct date range (7 days ago)', async () => {
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
of({ result: mockReturns, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.fetchIncompletedRemissionReturnReceipts();
|
||||||
|
|
||||||
|
const callArgs = mockReturnService.ReturnQueryReturns.mock.calls[0][0];
|
||||||
|
const startDate = new Date(callArgs.queryToken.start);
|
||||||
|
const expectedDate = subDays(new Date(), 7);
|
||||||
|
|
||||||
|
// Check that dates are within 1 second of each other (to handle timing differences)
|
||||||
|
expect(
|
||||||
|
Math.abs(startDate.getTime() - expectedDate.getTime()),
|
||||||
|
).toBeLessThan(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle abort signal', async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
of({ result: mockReturns, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.fetchIncompletedRemissionReturnReceipts(
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRemissionStockService.fetchAssignedStock).toHaveBeenCalledWith(
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
expect(mockReturnService.ReturnQueryReturns).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ResponseArgsError when API returns error', async () => {
|
||||||
|
const errorResponse = { error: 'API Error', result: null };
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(of(errorResponse));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.fetchIncompletedRemissionReturnReceipts(),
|
||||||
|
).rejects.toThrow(ResponseArgsError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when result is null', async () => {
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
of({ result: null, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.fetchIncompletedRemissionReturnReceipts();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when result is undefined', async () => {
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(of({ error: null }));
|
||||||
|
|
||||||
|
const result = await service.fetchIncompletedRemissionReturnReceipts();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle stock service errors', async () => {
|
||||||
|
mockRemissionStockService.fetchAssignedStock.mockRejectedValue(
|
||||||
|
new Error('Stock error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.fetchIncompletedRemissionReturnReceipts(),
|
||||||
|
).rejects.toThrow('Stock error');
|
||||||
|
expect(mockReturnService.ReturnQueryReturns).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle observable errors', async () => {
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
throwError(() => new Error('Observable error')),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.fetchIncompletedRemissionReturnReceipts(),
|
||||||
|
).rejects.toThrow('Observable error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRemissionStockService.fetchAssignedStock.mockResolvedValue(mockStock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty returns array', async () => {
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
of({ result: [], error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const completedResult =
|
||||||
|
await service.fetchCompletedRemissionReturnReceipts();
|
||||||
|
const incompletedResult =
|
||||||
|
await service.fetchIncompletedRemissionReturnReceipts();
|
||||||
|
|
||||||
|
expect(completedResult).toEqual([]);
|
||||||
|
expect(incompletedResult).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle stock with no id', async () => {
|
||||||
|
mockRemissionStockService.fetchAssignedStock.mockResolvedValue({
|
||||||
|
...mockStock,
|
||||||
|
id: undefined,
|
||||||
|
});
|
||||||
|
mockReturnService.ReturnQueryReturns.mockReturnValue(
|
||||||
|
of({ result: mockReturns, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.fetchCompletedRemissionReturnReceipts();
|
||||||
|
|
||||||
|
expect(mockReturnService.ReturnQueryReturns).toHaveBeenCalledWith({
|
||||||
|
stockId: undefined,
|
||||||
|
queryToken: expect.any(Object),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchRemissionReturnReceipt', () => {
|
||||||
|
const mockReceipt: Receipt = {
|
||||||
|
id: 101,
|
||||||
|
receiptNumber: 'REC-2024-001',
|
||||||
|
completed: '2024-01-15T10:30:00.000Z',
|
||||||
|
created: '2024-01-15T09:00:00.000Z',
|
||||||
|
items: [],
|
||||||
|
} as Receipt;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReturnService.ReturnGetReturnReceipt = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch return receipt successfully', async () => {
|
||||||
|
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
|
||||||
|
of({ result: mockReceipt, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = { receiptId: 101, returnId: 1 };
|
||||||
|
const result = await service.fetchRemissionReturnReceipt(params);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockReceipt);
|
||||||
|
expect(mockReturnService.ReturnGetReturnReceipt).toHaveBeenCalledWith({
|
||||||
|
receiptId: 101,
|
||||||
|
returnId: 1,
|
||||||
|
eagerLoading: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle abort signal', async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
|
||||||
|
of({ result: mockReceipt, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = { receiptId: 101, returnId: 1 };
|
||||||
|
await service.fetchRemissionReturnReceipt(params, abortController.signal);
|
||||||
|
|
||||||
|
expect(mockReturnService.ReturnGetReturnReceipt).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ResponseArgsError when API returns error', async () => {
|
||||||
|
const errorResponse = { error: 'API Error', result: null };
|
||||||
|
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
|
||||||
|
of(errorResponse),
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = { receiptId: 101, returnId: 1 };
|
||||||
|
await expect(service.fetchRemissionReturnReceipt(params)).rejects.toThrow(
|
||||||
|
ResponseArgsError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when result is null', async () => {
|
||||||
|
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
|
||||||
|
of({ result: null, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = { receiptId: 101, returnId: 1 };
|
||||||
|
const result = await service.fetchRemissionReturnReceipt(params);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when result is undefined', async () => {
|
||||||
|
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
|
||||||
|
of({ error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = { receiptId: 101, returnId: 1 };
|
||||||
|
const result = await service.fetchRemissionReturnReceipt(params);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle observable errors', async () => {
|
||||||
|
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
|
||||||
|
throwError(() => new Error('Observable error')),
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = { receiptId: 101, returnId: 1 };
|
||||||
|
await expect(service.fetchRemissionReturnReceipt(params)).rejects.toThrow(
|
||||||
|
'Observable error',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { ReturnService } from '@generated/swagger/inventory-api';
|
||||||
|
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
|
||||||
|
import { subDays } from 'date-fns';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { RemissionStockService } from './remission-stock.service';
|
||||||
|
import { Return } from '../models/return';
|
||||||
|
import {
|
||||||
|
FetchRemissionReturnParams,
|
||||||
|
FetchRemissionReturnReceiptSchema,
|
||||||
|
} from '../schemas';
|
||||||
|
import { Receipt } from '../models';
|
||||||
|
import { logger } from '@isa/core/logging';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service responsible for managing remission return receipts.
|
||||||
|
* Handles fetching completed and incomplete return receipts from the inventory API.
|
||||||
|
*
|
||||||
|
* @class RemissionReturnReceiptService
|
||||||
|
* @injectable
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Inject the service
|
||||||
|
* constructor(private remissionReturnReceiptService: RemissionReturnReceiptService) {}
|
||||||
|
*
|
||||||
|
* // Fetch completed receipts
|
||||||
|
* const completedReceipts = await this.remissionReturnReceiptService
|
||||||
|
* .fetchCompletedRemissionReturnReceipts();
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RemissionReturnReceiptService {
|
||||||
|
/** Private instance of the inventory return service */
|
||||||
|
#returnService = inject(ReturnService);
|
||||||
|
/** Private instance of the remission stock service */
|
||||||
|
#remissionStockService = inject(RemissionStockService);
|
||||||
|
/** Private logger instance */
|
||||||
|
#logger = logger(() => ({ service: 'RemissionReturnReceiptService' }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all completed remission return receipts for the assigned stock.
|
||||||
|
* Returns receipts marked as completed within the last 7 days.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
|
||||||
|
* @returns {Promise<Return[]>} Array of completed return objects with receipts
|
||||||
|
* @throws {ResponseArgsError} When the API request fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const controller = new AbortController();
|
||||||
|
* const completedReturns = await service
|
||||||
|
* .fetchCompletedRemissionReturnReceipts(controller.signal);
|
||||||
|
*/
|
||||||
|
async fetchCompletedRemissionReturnReceipts(
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<Return[]> {
|
||||||
|
this.#logger.debug('Fetching completed remission return receipts');
|
||||||
|
|
||||||
|
const assignedStock =
|
||||||
|
await this.#remissionStockService.fetchAssignedStock(abortSignal);
|
||||||
|
|
||||||
|
this.#logger.info('Fetching completed returns from API', () => ({
|
||||||
|
stockId: assignedStock.id,
|
||||||
|
startDate: subDays(new Date(), 7).toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let req$ = this.#returnService.ReturnQueryReturns({
|
||||||
|
stockId: assignedStock.id,
|
||||||
|
queryToken: {
|
||||||
|
input: { returncompleted: 'true' },
|
||||||
|
start: subDays(new Date(), 7).toISOString(),
|
||||||
|
eagerLoading: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (abortSignal) {
|
||||||
|
this.#logger.debug('Request configured with abort signal');
|
||||||
|
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
this.#logger.error('Failed to fetch completed returns', new Error(res.message || 'Unknown error'));
|
||||||
|
throw new ResponseArgsError(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const returns = (res?.result as Return[]) || [];
|
||||||
|
this.#logger.debug('Successfully fetched completed returns', () => ({
|
||||||
|
returnCount: returns.length
|
||||||
|
}));
|
||||||
|
|
||||||
|
return returns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all incomplete remission return receipts for the assigned stock.
|
||||||
|
* Returns receipts not yet marked as completed within the last 7 days.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
|
||||||
|
* @returns {Promise<Return[]>} Array of incomplete return objects with receipts
|
||||||
|
* @throws {ResponseArgsError} When the API request fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const incompleteReturns = await service
|
||||||
|
* .fetchIncompletedRemissionReturnReceipts();
|
||||||
|
*/
|
||||||
|
async fetchIncompletedRemissionReturnReceipts(
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<Return[]> {
|
||||||
|
this.#logger.debug('Fetching incomplete remission return receipts');
|
||||||
|
|
||||||
|
const assignedStock =
|
||||||
|
await this.#remissionStockService.fetchAssignedStock(abortSignal);
|
||||||
|
|
||||||
|
this.#logger.info('Fetching incomplete returns from API', () => ({
|
||||||
|
stockId: assignedStock.id,
|
||||||
|
startDate: subDays(new Date(), 7).toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let req$ = this.#returnService.ReturnQueryReturns({
|
||||||
|
stockId: assignedStock.id,
|
||||||
|
queryToken: {
|
||||||
|
input: { returncompleted: 'false' },
|
||||||
|
start: subDays(new Date(), 7).toISOString(),
|
||||||
|
eagerLoading: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (abortSignal) {
|
||||||
|
this.#logger.debug('Request configured with abort signal');
|
||||||
|
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
this.#logger.error('Failed to fetch incomplete returns', new Error(res.message || 'Unknown error'));
|
||||||
|
throw new ResponseArgsError(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const returns = (res?.result as Return[]) || [];
|
||||||
|
this.#logger.debug('Successfully fetched incomplete returns', () => ({
|
||||||
|
returnCount: returns.length
|
||||||
|
}));
|
||||||
|
|
||||||
|
return returns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a specific remission return receipt by receipt and return IDs.
|
||||||
|
* Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {FetchRemissionReturnParams} params - The receipt and return identifiers
|
||||||
|
* @param {FetchRemissionReturnParams} params.receiptId - ID of the receipt to fetch
|
||||||
|
* @param {FetchRemissionReturnParams} params.returnId - ID of the return containing the receipt
|
||||||
|
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
|
||||||
|
* @returns {Promise<Receipt | undefined>} The receipt object if found, undefined otherwise
|
||||||
|
* @throws {ResponseArgsError} When the API request fails
|
||||||
|
* @throws {z.ZodError} When parameter validation fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const receipt = await service.fetchRemissionReturnReceipt({
|
||||||
|
* receiptId: '123',
|
||||||
|
* returnId: '456'
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
async fetchRemissionReturnReceipt(
|
||||||
|
params: FetchRemissionReturnParams,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<Receipt | undefined> {
|
||||||
|
this.#logger.debug('Fetching remission return receipt', () => ({ params }));
|
||||||
|
|
||||||
|
const { receiptId, returnId } =
|
||||||
|
FetchRemissionReturnReceiptSchema.parse(params);
|
||||||
|
|
||||||
|
this.#logger.info('Fetching return receipt from API', () => ({
|
||||||
|
receiptId,
|
||||||
|
returnId
|
||||||
|
}));
|
||||||
|
|
||||||
|
let req$ = this.#returnService.ReturnGetReturnReceipt({
|
||||||
|
receiptId,
|
||||||
|
returnId,
|
||||||
|
eagerLoading: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (abortSignal) {
|
||||||
|
this.#logger.debug('Request configured with abort signal');
|
||||||
|
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
this.#logger.error('Failed to fetch return receipt', new Error(res.message || 'Unknown error'));
|
||||||
|
throw new ResponseArgsError(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = res?.result as Receipt | undefined;
|
||||||
|
this.#logger.debug('Successfully fetched return receipt', () => ({
|
||||||
|
found: !!receipt
|
||||||
|
}));
|
||||||
|
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,15 +15,52 @@ import {
|
|||||||
} from '../schemas';
|
} from '../schemas';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { ListResponseArgs } from '@isa/common/data-access';
|
import { ListResponseArgs } from '@isa/common/data-access';
|
||||||
|
import { logger } from '@isa/core/logging';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service responsible for remission search operations.
|
||||||
|
* Handles fetching remission lists, query settings, and managing remission list types.
|
||||||
|
*
|
||||||
|
* @class RemissionSearchService
|
||||||
|
* @injectable
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Inject the service
|
||||||
|
* constructor(private searchService: RemissionSearchService) {}
|
||||||
|
*
|
||||||
|
* // Get available remission list types
|
||||||
|
* const listTypes = this.searchService.remissionListType();
|
||||||
|
*
|
||||||
|
* // Fetch remission list
|
||||||
|
* const items = await this.searchService.fetchList({
|
||||||
|
* assignedStockId: 'stock123',
|
||||||
|
* supplierId: 'supplier456',
|
||||||
|
* take: 20,
|
||||||
|
* skip: 0
|
||||||
|
* });
|
||||||
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class RemissionSearchService {
|
export class RemissionSearchService {
|
||||||
#remiService = inject(RemiService);
|
#remiService = inject(RemiService);
|
||||||
|
#logger = logger(() => ({ service: 'RemissionSearchService' }));
|
||||||
|
|
||||||
remissionListType(): {
|
/**
|
||||||
|
* Returns all available remission list types as key-value pairs.
|
||||||
|
* This method provides a mapping between RemissionListTypeKey and RemissionListType values.
|
||||||
|
*
|
||||||
|
* @returns {Array<{key: RemissionListTypeKey, value: RemissionListType}>} Array of remission list type mappings
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const types = service.remissionListType();
|
||||||
|
* types.forEach(type => {
|
||||||
|
* console.log(`Type ${type.key}: ${type.value}`);
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
remissionListType(): Array<{
|
||||||
key: RemissionListTypeKey;
|
key: RemissionListTypeKey;
|
||||||
value: RemissionListType;
|
value: RemissionListType;
|
||||||
}[] {
|
}> {
|
||||||
|
this.#logger.debug('Getting remission list types');
|
||||||
return (Object.keys(RemissionListType) as RemissionListTypeKey[]).map(
|
return (Object.keys(RemissionListType) as RemissionListTypeKey[]).map(
|
||||||
(key) => ({
|
(key) => ({
|
||||||
key,
|
key,
|
||||||
@@ -32,9 +69,33 @@ export class RemissionSearchService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches query settings for mandatory remission articles.
|
||||||
|
* Validates input parameters using FetchQuerySettingsSchema.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {FetchQuerySettings} params - Parameters for the query settings request
|
||||||
|
* @param {string} params.supplierId - ID of the supplier
|
||||||
|
* @param {string} params.assignedStockId - ID of the assigned stock
|
||||||
|
* @returns {Promise<QuerySettings>} Query settings for the specified supplier and stock
|
||||||
|
* @throws {Error} When the API request fails or returns an error
|
||||||
|
* @throws {z.ZodError} When parameter validation fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const settings = await service.fetchQuerySettings({
|
||||||
|
* supplierId: 'supplier123',
|
||||||
|
* assignedStockId: 'stock456'
|
||||||
|
* });
|
||||||
|
*/
|
||||||
async fetchQuerySettings(params: FetchQuerySettings): Promise<QuerySettings> {
|
async fetchQuerySettings(params: FetchQuerySettings): Promise<QuerySettings> {
|
||||||
|
this.#logger.debug('Fetching query settings', () => ({ params }));
|
||||||
const parsed = FetchQuerySettingsSchema.parse(params);
|
const parsed = FetchQuerySettingsSchema.parse(params);
|
||||||
|
|
||||||
|
this.#logger.info('Fetching query settings from API', () => ({
|
||||||
|
supplierId: parsed.supplierId,
|
||||||
|
stockId: parsed.assignedStockId
|
||||||
|
}));
|
||||||
|
|
||||||
const req$ = this.#remiService.RemiPflichtremissionsartikelSettings({
|
const req$ = this.#remiService.RemiPflichtremissionsartikelSettings({
|
||||||
supplierId: parsed.supplierId,
|
supplierId: parsed.supplierId,
|
||||||
stockId: parsed.assignedStockId,
|
stockId: parsed.assignedStockId,
|
||||||
@@ -43,17 +104,44 @@ export class RemissionSearchService {
|
|||||||
const res = await firstValueFrom(req$);
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
if (res.error || !res.result) {
|
if (res.error || !res.result) {
|
||||||
throw new Error(res.message || 'Failed to fetch Query Settings');
|
const error = new Error(res.message || 'Failed to fetch Query Settings');
|
||||||
|
this.#logger.error('Failed to fetch query settings', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.#logger.debug('Successfully fetched query settings');
|
||||||
return res.result as QuerySettings;
|
return res.result as QuerySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches query settings for department overflow remission articles.
|
||||||
|
* Validates input parameters using FetchQuerySettingsSchema.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {FetchQuerySettings} params - Parameters for the department query settings request
|
||||||
|
* @param {string} params.supplierId - ID of the supplier
|
||||||
|
* @param {string} params.assignedStockId - ID of the assigned stock
|
||||||
|
* @returns {Promise<QuerySettings>} Department query settings for the specified supplier and stock
|
||||||
|
* @throws {Error} When the API request fails or returns an error
|
||||||
|
* @throws {z.ZodError} When parameter validation fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const departmentSettings = await service.fetchQueryDepartmentSettings({
|
||||||
|
* supplierId: 'supplier123',
|
||||||
|
* assignedStockId: 'stock456'
|
||||||
|
* });
|
||||||
|
*/
|
||||||
async fetchQueryDepartmentSettings(
|
async fetchQueryDepartmentSettings(
|
||||||
params: FetchQuerySettings,
|
params: FetchQuerySettings,
|
||||||
): Promise<QuerySettings> {
|
): Promise<QuerySettings> {
|
||||||
|
this.#logger.debug('Fetching department query settings', () => ({ params }));
|
||||||
const parsed = FetchQuerySettingsSchema.parse(params);
|
const parsed = FetchQuerySettingsSchema.parse(params);
|
||||||
|
|
||||||
|
this.#logger.info('Fetching department query settings from API', () => ({
|
||||||
|
supplierId: parsed.supplierId,
|
||||||
|
stockId: parsed.assignedStockId
|
||||||
|
}));
|
||||||
|
|
||||||
const req$ = this.#remiService.RemiUeberlaufSettings({
|
const req$ = this.#remiService.RemiUeberlaufSettings({
|
||||||
supplierId: parsed.supplierId,
|
supplierId: parsed.supplierId,
|
||||||
stockId: parsed.assignedStockId,
|
stockId: parsed.assignedStockId,
|
||||||
@@ -62,20 +150,60 @@ export class RemissionSearchService {
|
|||||||
const res = await firstValueFrom(req$);
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
if (res.error || !res.result) {
|
if (res.error || !res.result) {
|
||||||
throw new Error(
|
const error = new Error(
|
||||||
res.message || 'Failed to fetch Query Department Settings',
|
res.message || 'Failed to fetch Query Department Settings',
|
||||||
);
|
);
|
||||||
|
this.#logger.error('Failed to fetch department query settings', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.#logger.debug('Successfully fetched department query settings');
|
||||||
return res.result as QuerySettings;
|
return res.result as QuerySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a paginated list of mandatory remission return items.
|
||||||
|
* Validates input parameters using RemissionQueryTokenSchema.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {RemissionQueryTokenInput} params - Query parameters for the list request
|
||||||
|
* @param {string} params.assignedStockId - ID of the assigned stock
|
||||||
|
* @param {string} params.supplierId - ID of the supplier
|
||||||
|
* @param {string} [params.filter] - Optional filter string
|
||||||
|
* @param {Object} [params.input] - Optional input parameters for filtering
|
||||||
|
* @param {string} [params.orderBy] - Optional field to order results by
|
||||||
|
* @param {number} [params.take] - Number of items to fetch (pagination)
|
||||||
|
* @param {number} [params.skip] - Number of items to skip (pagination)
|
||||||
|
* @returns {Promise<ListResponseArgs<ReturnItem>>} Paginated list response with return items
|
||||||
|
* @throws {Error} When the API request fails or returns an error
|
||||||
|
* @throws {z.ZodError} When parameter validation fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const response = await service.fetchList({
|
||||||
|
* assignedStockId: 'stock123',
|
||||||
|
* supplierId: 'supplier456',
|
||||||
|
* take: 20,
|
||||||
|
* skip: 0,
|
||||||
|
* orderBy: 'itemName'
|
||||||
|
* });
|
||||||
|
* console.log(`Total items: ${response.totalCount}`);
|
||||||
|
*
|
||||||
|
* @todo After fetching, StockInStock should be called in the old DomainRemissionService
|
||||||
|
*/
|
||||||
// TODO: Im alten DomainRemissionService wird danach StockInStock abgerufen
|
// TODO: Im alten DomainRemissionService wird danach StockInStock abgerufen
|
||||||
async fetchList(
|
async fetchList(
|
||||||
params: RemissionQueryTokenInput,
|
params: RemissionQueryTokenInput,
|
||||||
): Promise<ListResponseArgs<ReturnItem>> {
|
): Promise<ListResponseArgs<ReturnItem>> {
|
||||||
|
this.#logger.debug('Fetching remission list', () => ({ params }));
|
||||||
const parsed = RemissionQueryTokenSchema.parse(params);
|
const parsed = RemissionQueryTokenSchema.parse(params);
|
||||||
|
|
||||||
|
this.#logger.info('Fetching remission list from API', () => ({
|
||||||
|
stockId: parsed.assignedStockId,
|
||||||
|
supplierId: parsed.supplierId,
|
||||||
|
take: parsed.take,
|
||||||
|
skip: parsed.skip
|
||||||
|
}));
|
||||||
|
|
||||||
const req$ = this.#remiService.RemiPflichtremissionsartikel({
|
const req$ = this.#remiService.RemiPflichtremissionsartikel({
|
||||||
queryToken: {
|
queryToken: {
|
||||||
stockId: parsed.assignedStockId,
|
stockId: parsed.assignedStockId,
|
||||||
@@ -91,18 +219,60 @@ export class RemissionSearchService {
|
|||||||
const res = await firstValueFrom(req$);
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
if (res.error || !res.result) {
|
if (res.error || !res.result) {
|
||||||
throw new Error(res.message || 'Failed to fetch Remission List');
|
const error = new Error(res.message || 'Failed to fetch Remission List');
|
||||||
|
this.#logger.error('Failed to fetch remission list', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.#logger.debug('Successfully fetched remission list', () => ({
|
||||||
|
itemCount: res.result?.length || 0,
|
||||||
|
totalCount: (res as any).totalCount
|
||||||
|
}));
|
||||||
|
|
||||||
return res as ListResponseArgs<ReturnItem>;
|
return res as ListResponseArgs<ReturnItem>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a paginated list of department overflow remission suggestions.
|
||||||
|
* Validates input parameters using RemissionQueryTokenSchema.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {RemissionQueryTokenInput} params - Query parameters for the department list request
|
||||||
|
* @param {string} params.assignedStockId - ID of the assigned stock
|
||||||
|
* @param {string} params.supplierId - ID of the supplier
|
||||||
|
* @param {string} [params.filter] - Optional filter string
|
||||||
|
* @param {Object} [params.input] - Optional input parameters for filtering
|
||||||
|
* @param {string} [params.orderBy] - Optional field to order results by
|
||||||
|
* @param {number} [params.take] - Number of items to fetch (pagination)
|
||||||
|
* @param {number} [params.skip] - Number of items to skip (pagination)
|
||||||
|
* @returns {Promise<ListResponseArgs<ReturnSuggestion>>} Paginated list response with return suggestions
|
||||||
|
* @throws {Error} When the API request fails or returns an error
|
||||||
|
* @throws {z.ZodError} When parameter validation fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const departmentResponse = await service.fetchDepartmentList({
|
||||||
|
* assignedStockId: 'stock123',
|
||||||
|
* supplierId: 'supplier456',
|
||||||
|
* take: 50,
|
||||||
|
* skip: 0
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @todo After fetching, StockInStock should be called in the old DomainRemissionService
|
||||||
|
*/
|
||||||
// TODO: Im alten DomainRemissionService wird danach StockInStock abgerufen
|
// TODO: Im alten DomainRemissionService wird danach StockInStock abgerufen
|
||||||
async fetchDepartmentList(
|
async fetchDepartmentList(
|
||||||
params: RemissionQueryTokenInput,
|
params: RemissionQueryTokenInput,
|
||||||
): Promise<ListResponseArgs<ReturnSuggestion>> {
|
): Promise<ListResponseArgs<ReturnSuggestion>> {
|
||||||
|
this.#logger.debug('Fetching department remission list', () => ({ params }));
|
||||||
const parsed = RemissionQueryTokenSchema.parse(params);
|
const parsed = RemissionQueryTokenSchema.parse(params);
|
||||||
|
|
||||||
|
this.#logger.info('Fetching department remission list from API', () => ({
|
||||||
|
stockId: parsed.assignedStockId,
|
||||||
|
supplierId: parsed.supplierId,
|
||||||
|
take: parsed.take,
|
||||||
|
skip: parsed.skip
|
||||||
|
}));
|
||||||
|
|
||||||
const req$ = this.#remiService.RemiUeberlauf({
|
const req$ = this.#remiService.RemiUeberlauf({
|
||||||
queryToken: {
|
queryToken: {
|
||||||
stockId: parsed.assignedStockId,
|
stockId: parsed.assignedStockId,
|
||||||
@@ -118,11 +288,18 @@ export class RemissionSearchService {
|
|||||||
const res = await firstValueFrom(req$);
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
if (res.error || !res.result) {
|
if (res.error || !res.result) {
|
||||||
throw new Error(
|
const error = new Error(
|
||||||
res.message || 'Failed to fetch Remission Department List',
|
res.message || 'Failed to fetch Remission Department List',
|
||||||
);
|
);
|
||||||
|
this.#logger.error('Failed to fetch department remission list', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.#logger.debug('Successfully fetched department remission list', () => ({
|
||||||
|
suggestionCount: res.result?.length || 0,
|
||||||
|
totalCount: (res as any).totalCount
|
||||||
|
}));
|
||||||
|
|
||||||
return res as ListResponseArgs<ReturnSuggestion>;
|
return res as ListResponseArgs<ReturnSuggestion>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { RemissionStockService } from './remission-stock.service';
|
||||||
|
import { StockService } from '@generated/swagger/inventory-api';
|
||||||
|
import { ResponseArgsError } from '@isa/common/data-access';
|
||||||
|
import { Stock, StockInfo } from '../models';
|
||||||
|
|
||||||
|
jest.mock('@generated/swagger/inventory-api', () => ({
|
||||||
|
StockService: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for RemissionStockService.
|
||||||
|
* Tests the service's ability to fetch and cache stock information.
|
||||||
|
*
|
||||||
|
* @group unit
|
||||||
|
* @group services
|
||||||
|
*/
|
||||||
|
describe('RemissionStockService', () => {
|
||||||
|
let service: RemissionStockService;
|
||||||
|
let mockStockService: {
|
||||||
|
StockCurrentStock: jest.Mock;
|
||||||
|
StockInStock: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockStock: Stock = {
|
||||||
|
id: 123,
|
||||||
|
name: 'Test Stock',
|
||||||
|
description: 'Test Description',
|
||||||
|
} as Stock;
|
||||||
|
|
||||||
|
const mockStockInfo: StockInfo[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
itemId: 100,
|
||||||
|
quantity: 10,
|
||||||
|
stockId: 123,
|
||||||
|
} as StockInfo,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
itemId: 101,
|
||||||
|
quantity: 5,
|
||||||
|
stockId: 123,
|
||||||
|
} as StockInfo,
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear all mocks before each test
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
mockStockService = {
|
||||||
|
StockCurrentStock: jest.fn(),
|
||||||
|
StockInStock: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
RemissionStockService,
|
||||||
|
{ provide: StockService, useValue: mockStockService },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(RemissionStockService);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for fetchAssignedStock method.
|
||||||
|
* Verifies caching behavior and API interaction.
|
||||||
|
*/
|
||||||
|
describe('fetchAssignedStock', () => {
|
||||||
|
it('should fetch stock from API', async () => {
|
||||||
|
mockStockService.StockCurrentStock.mockReturnValue(
|
||||||
|
of({ result: mockStock, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.fetchAssignedStock();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockStock);
|
||||||
|
expect(mockStockService.StockCurrentStock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ResponseArgsError when API returns error', async () => {
|
||||||
|
const errorResponse = { error: 'API Error', result: null };
|
||||||
|
mockStockService.StockCurrentStock.mockReturnValue(of(errorResponse));
|
||||||
|
|
||||||
|
await expect(service.fetchAssignedStock()).rejects.toThrow(
|
||||||
|
ResponseArgsError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ResponseArgsError when API returns no result', async () => {
|
||||||
|
mockStockService.StockCurrentStock.mockReturnValue(
|
||||||
|
of({ error: null, result: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.fetchAssignedStock()).rejects.toThrow(
|
||||||
|
ResponseArgsError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle abort signal', async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const mockObservable = {
|
||||||
|
pipe: jest.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
mockStockService.StockCurrentStock.mockReturnValue(mockObservable);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.fetchAssignedStock(abortController.signal);
|
||||||
|
} catch {
|
||||||
|
// Expected to fail since we're not properly mocking the observable
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockObservable.pipe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API observable errors', async () => {
|
||||||
|
mockStockService.StockCurrentStock.mockReturnValue(
|
||||||
|
throwError(() => new Error('Network error')),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.fetchAssignedStock()).rejects.toThrow(
|
||||||
|
'Network error',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for fetchStock method.
|
||||||
|
* Verifies stock information fetching with item IDs.
|
||||||
|
*/
|
||||||
|
describe('fetchStock', () => {
|
||||||
|
/**
|
||||||
|
* Valid test parameters for stock fetching.
|
||||||
|
*/
|
||||||
|
const validParams = {
|
||||||
|
assignedStockId: 123,
|
||||||
|
itemIds: [100, 101],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should fetch stock info successfully', async () => {
|
||||||
|
mockStockService.StockInStock.mockReturnValue(
|
||||||
|
of({ result: mockStockInfo, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.fetchStock(validParams);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockStockInfo);
|
||||||
|
expect(mockStockService.StockInStock).toHaveBeenCalledWith({
|
||||||
|
stockId: 123,
|
||||||
|
articleIds: [100, 101],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ResponseArgsError when API returns error', async () => {
|
||||||
|
const errorResponse = { error: 'API Error', result: null };
|
||||||
|
mockStockService.StockInStock.mockReturnValue(of(errorResponse));
|
||||||
|
|
||||||
|
await expect(service.fetchStock(validParams)).rejects.toThrow(
|
||||||
|
ResponseArgsError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ResponseArgsError when API returns no result', async () => {
|
||||||
|
mockStockService.StockInStock.mockReturnValue(
|
||||||
|
of({ error: null, result: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.fetchStock(validParams)).rejects.toThrow(
|
||||||
|
ResponseArgsError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle abort signal', async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const mockObservable = {
|
||||||
|
pipe: jest.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
mockStockService.StockInStock.mockReturnValue(mockObservable);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.fetchStock(validParams, abortController.signal);
|
||||||
|
} catch {
|
||||||
|
// Expected to fail since we're not properly mocking the observable
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockObservable.pipe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate params with schema', async () => {
|
||||||
|
// This will be validated by the schema
|
||||||
|
const invalidParams = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
assignedStockId: 'invalid' as any, // Should be number
|
||||||
|
itemIds: [100, 101],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStockService.StockInStock.mockReturnValue(
|
||||||
|
of({ result: mockStockInfo, error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The schema parsing should throw an error for invalid params
|
||||||
|
await expect(service.fetchStock(invalidParams)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty itemIds array', async () => {
|
||||||
|
const paramsWithEmptyItems = {
|
||||||
|
assignedStockId: 123,
|
||||||
|
itemIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStockService.StockInStock.mockReturnValue(
|
||||||
|
of({ result: [], error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.fetchStock(paramsWithEmptyItems);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(mockStockService.StockInStock).toHaveBeenCalledWith({
|
||||||
|
stockId: 123,
|
||||||
|
articleIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API observable errors', async () => {
|
||||||
|
mockStockService.StockInStock.mockReturnValue(
|
||||||
|
throwError(() => new Error('Network error')),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.fetchStock(validParams)).rejects.toThrow(
|
||||||
|
'Network error',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when API returns empty result', async () => {
|
||||||
|
mockStockService.StockInStock.mockReturnValue(
|
||||||
|
of({ result: [], error: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.fetchStock(validParams);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,63 +1,143 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { StockService } from '@generated/swagger/inventory-api';
|
import { StockService } from '@generated/swagger/inventory-api';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { Stock, StockInfo } from '../models';
|
import { Stock, StockInfo } from '../models';
|
||||||
import { FetchStockInStock, FetchStockInStockSchema } from '../schemas';
|
import { FetchStockInStock, FetchStockInStockSchema } from '../schemas';
|
||||||
import { injectStorage, MemoryStorageProvider } from '@isa/core/storage';
|
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
|
||||||
import { ASSIGNED_STOCK_STORAGE_KEY } from '../constants';
|
import { logger } from '@isa/core/logging';
|
||||||
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
|
import { InFlightWithCache } from '@isa/common/decorators';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
/**
|
||||||
export class RemissionStockService {
|
* Service responsible for managing remission stock operations.
|
||||||
#stockService = inject(StockService);
|
* Handles fetching assigned stock and stock information from the inventory API.
|
||||||
#memoryStorage = injectStorage(MemoryStorageProvider);
|
*
|
||||||
|
* @class RemissionStockService
|
||||||
async fetchAssignedStock(abortSignal?: AbortSignal): Promise<Stock> {
|
* @injectable
|
||||||
// TODO: No caching in data-access services. Remove caching.
|
*
|
||||||
const cached = await this.#memoryStorage.get(ASSIGNED_STOCK_STORAGE_KEY);
|
* @example
|
||||||
|
* // Inject the service
|
||||||
if (cached) {
|
* constructor(private stockService: RemissionStockService) {}
|
||||||
return cached as Stock;
|
*
|
||||||
}
|
* // Fetch assigned stock
|
||||||
|
* const assignedStock = await this.stockService.fetchAssignedStock();
|
||||||
const req$ = this.#stockService.StockCurrentStock();
|
*
|
||||||
|
* // Fetch stock info for specific items
|
||||||
if (abortSignal) {
|
* const stockInfo = await this.stockService.fetchStock({
|
||||||
req$.pipe(takeUntilAborted(abortSignal));
|
* assignedStockId: 'stock123',
|
||||||
}
|
* itemIds: ['item1', 'item2']
|
||||||
|
* });
|
||||||
const res = await firstValueFrom(req$);
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
if (res.error || !res.result) {
|
export class RemissionStockService {
|
||||||
throw new ResponseArgsError(res);
|
#stockService = inject(StockService);
|
||||||
}
|
#logger = logger(() => ({ service: 'RemissionStockService' }));
|
||||||
|
|
||||||
this.#memoryStorage.set(ASSIGNED_STOCK_STORAGE_KEY, res.result);
|
/**
|
||||||
|
* Fetches the currently assigned stock for the user.
|
||||||
return res.result as Stock;
|
* Results are cached in memory to reduce API calls.
|
||||||
}
|
*
|
||||||
|
* @async
|
||||||
async fetchStock(
|
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
|
||||||
params: FetchStockInStock,
|
* @returns {Promise<Stock>} The assigned stock object
|
||||||
abortSignal?: AbortSignal,
|
* @throws {ResponseArgsError} When the API request fails or returns an error
|
||||||
): Promise<StockInfo[]> {
|
*
|
||||||
const parsed = FetchStockInStockSchema.parse(params);
|
* @example
|
||||||
|
* const controller = new AbortController();
|
||||||
const req$ = this.#stockService.StockInStock({
|
* try {
|
||||||
stockId: parsed.assignedStockId,
|
* const stock = await service.fetchAssignedStock(controller.signal);
|
||||||
articleIds: parsed.itemIds,
|
* console.log('Assigned stock:', stock.id);
|
||||||
});
|
* } catch (error) {
|
||||||
|
* console.error('Failed to fetch assigned stock:', error);
|
||||||
if (abortSignal) {
|
* }
|
||||||
req$.pipe(takeUntilAborted(abortSignal));
|
*
|
||||||
}
|
* @todo Remove caching from data-access services
|
||||||
|
*/
|
||||||
const res = await firstValueFrom(req$);
|
@InFlightWithCache()
|
||||||
|
async fetchAssignedStock(abortSignal?: AbortSignal): Promise<Stock> {
|
||||||
if (res.error || !res.result) {
|
this.#logger.info('Fetching assigned stock from API');
|
||||||
throw new ResponseArgsError(res);
|
let req$ = this.#stockService.StockCurrentStock();
|
||||||
}
|
|
||||||
|
if (abortSignal) {
|
||||||
return res.result as StockInfo[];
|
this.#logger.debug('Request configured with abort signal');
|
||||||
}
|
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
|
if (res.error || !res.result) {
|
||||||
|
this.#logger.error(
|
||||||
|
'Failed to fetch assigned stock',
|
||||||
|
new Error(res.message || 'Unknown error'),
|
||||||
|
);
|
||||||
|
throw new ResponseArgsError(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.debug('Successfully fetched assigned stock', () => ({
|
||||||
|
stockId: res.result?.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.result as Stock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches stock information for specific items in the assigned stock.
|
||||||
|
* Validates input parameters using FetchStockInStockSchema.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {FetchStockInStock} params - Parameters for the stock query
|
||||||
|
* @param {string} params.assignedStockId - ID of the assigned stock
|
||||||
|
* @param {string[]} params.itemIds - Array of item IDs to fetch stock info for
|
||||||
|
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
|
||||||
|
* @returns {Promise<StockInfo[]>} Array of stock information for the requested items
|
||||||
|
* @throws {ResponseArgsError} When the API request fails or returns an error
|
||||||
|
* @throws {z.ZodError} When parameter validation fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const stockInfo = await service.fetchStock({
|
||||||
|
* assignedStockId: 'stock123',
|
||||||
|
* itemIds: ['item1', 'item2', 'item3']
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* stockInfo.forEach(info => {
|
||||||
|
* console.log(`Item ${info.itemId}: ${info.quantity} in stock`);
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
async fetchStock(
|
||||||
|
params: FetchStockInStock,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<StockInfo[]> {
|
||||||
|
this.#logger.debug('Fetching stock info', () => ({ params }));
|
||||||
|
const parsed = FetchStockInStockSchema.parse(params);
|
||||||
|
|
||||||
|
this.#logger.info('Fetching stock info from API', () => ({
|
||||||
|
stockId: parsed.assignedStockId,
|
||||||
|
itemCount: parsed.itemIds.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let req$ = this.#stockService.StockInStock({
|
||||||
|
stockId: parsed.assignedStockId,
|
||||||
|
articleIds: parsed.itemIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (abortSignal) {
|
||||||
|
this.#logger.debug('Request configured with abort signal');
|
||||||
|
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
|
if (res.error || !res.result) {
|
||||||
|
this.#logger.error(
|
||||||
|
'Failed to fetch stock info',
|
||||||
|
new Error(res.message || 'Unknown error'),
|
||||||
|
);
|
||||||
|
throw new ResponseArgsError(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.debug('Successfully fetched stock info', () => ({
|
||||||
|
itemCount: res.result?.length || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.result as StockInfo[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { RemissionSupplierService } from './remission-supplier.service';
|
||||||
|
import { SupplierService } from '@generated/swagger/inventory-api';
|
||||||
|
import { RemissionStockService } from './remission-stock.service';
|
||||||
|
import { DataAccessError } from '@isa/common/data-access';
|
||||||
|
import { Supplier, Stock } from '../models';
|
||||||
|
import { SUPPLIER_STORAGE_KEY } from '../constants';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
// Mock injectStorage at the module level
|
||||||
|
const mockMemoryStorage = {
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('@isa/core/storage', () => ({
|
||||||
|
injectStorage: jest.fn(() => mockMemoryStorage),
|
||||||
|
MemoryStorageProvider: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RemissionSupplierService', () => {
|
||||||
|
let service: RemissionSupplierService;
|
||||||
|
let mockSupplierService: jest.Mocked<SupplierService>;
|
||||||
|
let mockStockService: jest.Mocked<RemissionStockService>;
|
||||||
|
|
||||||
|
const mockSuppliers: Supplier[] = [
|
||||||
|
{
|
||||||
|
id: 123,
|
||||||
|
name: 'Test Supplier GmbH',
|
||||||
|
active: true,
|
||||||
|
} as Supplier,
|
||||||
|
{
|
||||||
|
id: 456,
|
||||||
|
name: 'Another Supplier Ltd',
|
||||||
|
active: true,
|
||||||
|
} as Supplier,
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockStock: Stock = {
|
||||||
|
id: 789,
|
||||||
|
name: 'Test Stock',
|
||||||
|
active: true,
|
||||||
|
} as Stock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear all mocks before each test
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
const supplierServiceSpy = {
|
||||||
|
SupplierGetSuppliers: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stockServiceSpy = {
|
||||||
|
fetchAssignedStock: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
RemissionSupplierService,
|
||||||
|
{ provide: SupplierService, useValue: supplierServiceSpy },
|
||||||
|
{ provide: RemissionStockService, useValue: stockServiceSpy },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(RemissionSupplierService);
|
||||||
|
mockSupplierService = TestBed.inject(
|
||||||
|
SupplierService,
|
||||||
|
) as jest.Mocked<SupplierService>;
|
||||||
|
mockStockService = TestBed.inject(
|
||||||
|
RemissionStockService,
|
||||||
|
) as jest.Mocked<RemissionStockService>;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Service Creation', () => {
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchSuppliers - Cache Hit', () => {
|
||||||
|
it('should return cached suppliers when available', async () => {
|
||||||
|
mockMemoryStorage.get.mockResolvedValue(mockSuppliers);
|
||||||
|
|
||||||
|
const result = await service.fetchSuppliers();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSuppliers);
|
||||||
|
expect(mockMemoryStorage.get).toHaveBeenCalledWith(SUPPLIER_STORAGE_KEY);
|
||||||
|
expect(mockStockService.fetchAssignedStock).not.toHaveBeenCalled();
|
||||||
|
expect(mockSupplierService.SupplierGetSuppliers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use cached data with abortSignal', async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
mockMemoryStorage.get.mockResolvedValue(mockSuppliers);
|
||||||
|
|
||||||
|
const result = await service.fetchSuppliers(abortController.signal);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSuppliers);
|
||||||
|
expect(mockMemoryStorage.get).toHaveBeenCalledWith(SUPPLIER_STORAGE_KEY);
|
||||||
|
expect(mockStockService.fetchAssignedStock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchSuppliers - API Success', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMemoryStorage.get.mockResolvedValue(null);
|
||||||
|
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch suppliers from API when cache is empty', async () => {
|
||||||
|
const apiResponse = {
|
||||||
|
result: mockSuppliers,
|
||||||
|
error: false,
|
||||||
|
message: undefined,
|
||||||
|
};
|
||||||
|
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
|
||||||
|
|
||||||
|
const result = await service.fetchSuppliers();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSuppliers);
|
||||||
|
expect(mockSupplierService.SupplierGetSuppliers).toHaveBeenCalledWith({
|
||||||
|
stockId: mockStock.id,
|
||||||
|
});
|
||||||
|
expect(mockMemoryStorage.set).toHaveBeenCalledWith(
|
||||||
|
SUPPLIER_STORAGE_KEY,
|
||||||
|
mockSuppliers,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty suppliers from API', async () => {
|
||||||
|
const emptySuppliers: Supplier[] = [];
|
||||||
|
const apiResponse = {
|
||||||
|
result: emptySuppliers,
|
||||||
|
error: false,
|
||||||
|
message: undefined,
|
||||||
|
};
|
||||||
|
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
|
||||||
|
|
||||||
|
const result = await service.fetchSuppliers();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(mockMemoryStorage.set).toHaveBeenCalledWith(
|
||||||
|
SUPPLIER_STORAGE_KEY,
|
||||||
|
emptySuppliers,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass abortSignal to stock service', async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const apiResponse = {
|
||||||
|
result: mockSuppliers,
|
||||||
|
error: false,
|
||||||
|
message: undefined,
|
||||||
|
};
|
||||||
|
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
|
||||||
|
|
||||||
|
await service.fetchSuppliers(abortController.signal);
|
||||||
|
|
||||||
|
expect(mockStockService.fetchAssignedStock).toHaveBeenCalledWith(
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchSuppliers - Error Cases', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMemoryStorage.get.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw DataAccessError when no assigned stock', async () => {
|
||||||
|
mockStockService.fetchAssignedStock.mockResolvedValue(null as unknown as Stock);
|
||||||
|
|
||||||
|
await expect(service.fetchSuppliers()).rejects.toThrow(DataAccessError);
|
||||||
|
await expect(service.fetchSuppliers()).rejects.toThrow(
|
||||||
|
'No assigned stock found',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when API returns error', async () => {
|
||||||
|
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
|
||||||
|
const apiResponse = {
|
||||||
|
result: undefined,
|
||||||
|
error: true,
|
||||||
|
message: 'API Error',
|
||||||
|
};
|
||||||
|
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
|
||||||
|
|
||||||
|
await expect(service.fetchSuppliers()).rejects.toThrow('API Error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw default error when no message', async () => {
|
||||||
|
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
|
||||||
|
const apiResponse = {
|
||||||
|
result: undefined,
|
||||||
|
error: true,
|
||||||
|
message: undefined,
|
||||||
|
};
|
||||||
|
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
|
||||||
|
|
||||||
|
await expect(service.fetchSuppliers()).rejects.toThrow(
|
||||||
|
'Failed to fetch Suppliers',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when result is undefined', async () => {
|
||||||
|
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
|
||||||
|
const apiResponse = {
|
||||||
|
result: undefined,
|
||||||
|
error: false,
|
||||||
|
message: 'No data',
|
||||||
|
};
|
||||||
|
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
|
||||||
|
|
||||||
|
await expect(service.fetchSuppliers()).rejects.toThrow('No data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle service errors', async () => {
|
||||||
|
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
|
||||||
|
const error = new Error('Service error');
|
||||||
|
mockSupplierService.SupplierGetSuppliers.mockReturnValue(
|
||||||
|
throwError(() => error),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.fetchSuppliers()).rejects.toThrow('Service error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration', () => {
|
||||||
|
it('should properly cache API responses', async () => {
|
||||||
|
mockMemoryStorage.get.mockResolvedValue(null);
|
||||||
|
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
|
||||||
|
|
||||||
|
const apiResponse = {
|
||||||
|
result: mockSuppliers,
|
||||||
|
error: false,
|
||||||
|
message: undefined,
|
||||||
|
};
|
||||||
|
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
|
||||||
|
|
||||||
|
const result = await service.fetchSuppliers();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSuppliers);
|
||||||
|
expect(mockMemoryStorage.set).toHaveBeenCalledWith(
|
||||||
|
SUPPLIER_STORAGE_KEY,
|
||||||
|
mockSuppliers,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle storage errors', async () => {
|
||||||
|
const storageError = new Error('Storage error');
|
||||||
|
mockMemoryStorage.get.mockRejectedValue(storageError);
|
||||||
|
|
||||||
|
await expect(service.fetchSuppliers()).rejects.toThrow('Storage error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,37 +1,112 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { SupplierService } from '@generated/swagger/inventory-api';
|
import { SupplierService } from '@generated/swagger/inventory-api';
|
||||||
import { Supplier } from '../models';
|
import { Supplier } from '../models';
|
||||||
import { FetchSuppliers, FetchSuppliersSchema } from '../schemas';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { injectStorage, MemoryStorageProvider } from '@isa/core/storage';
|
||||||
import { injectStorage, MemoryStorageProvider } from '@isa/core/storage';
|
import { SUPPLIER_STORAGE_KEY } from '../constants';
|
||||||
import { SUPPLIER_STORAGE_KEY } from '../constants';
|
import { RemissionStockService } from './remission-stock.service';
|
||||||
|
import { DataAccessError, takeUntilAborted } from '@isa/common/data-access';
|
||||||
@Injectable({ providedIn: 'root' })
|
import { logger } from '@isa/core/logging';
|
||||||
export class RemissionSupplierService {
|
|
||||||
#supplierService = inject(SupplierService);
|
/**
|
||||||
#memoryStorage = injectStorage(MemoryStorageProvider);
|
* Service for managing remission suppliers.
|
||||||
|
* Handles fetching and caching supplier data for the assigned stock.
|
||||||
async fetchSuppliers(params: FetchSuppliers): Promise<Supplier[]> {
|
*
|
||||||
const cached = await this.#memoryStorage.get(SUPPLIER_STORAGE_KEY); // TODO: Schema Validierung erstellen
|
* @class RemissionSupplierService
|
||||||
|
* @injectable
|
||||||
if (cached) {
|
*
|
||||||
return cached as Supplier[];
|
* @example
|
||||||
}
|
* // Inject the service
|
||||||
|
* constructor(private supplierService: RemissionSupplierService) {}
|
||||||
const parsed = FetchSuppliersSchema.parse(params);
|
*
|
||||||
|
* // Fetch suppliers
|
||||||
const req$ = this.#supplierService.SupplierGetSuppliers({
|
* const suppliers = await this.supplierService.fetchSuppliers();
|
||||||
stockId: parsed.assignedStockId,
|
*/
|
||||||
});
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RemissionSupplierService {
|
||||||
const res = await firstValueFrom(req$);
|
/** Private instance of the supplier service from inventory API */
|
||||||
|
#supplierService = inject(SupplierService);
|
||||||
if (res.error || !res.result) {
|
/** Private instance of the remission stock service */
|
||||||
throw new Error(res.message || 'Failed to fetch Suppliers');
|
#stockService = inject(RemissionStockService);
|
||||||
}
|
/** Private memory storage for caching suppliers */
|
||||||
|
#memoryStorage = injectStorage(MemoryStorageProvider);
|
||||||
this.#memoryStorage.set(SUPPLIER_STORAGE_KEY, res.result);
|
/** Private logger instance */
|
||||||
|
#logger = logger(() => ({ service: 'RemissionSupplierService' }));
|
||||||
return res.result as Supplier[];
|
|
||||||
}
|
/**
|
||||||
}
|
* Fetches all suppliers for the currently assigned stock.
|
||||||
|
* Results are cached in memory storage to reduce API calls.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
|
||||||
|
* @returns {Promise<Supplier[]>} Array of suppliers for the assigned stock
|
||||||
|
* @throws {DataAccessError} When no assigned stock is found
|
||||||
|
* @throws {Error} When the API request fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const controller = new AbortController();
|
||||||
|
* try {
|
||||||
|
* const suppliers = await service.fetchSuppliers(controller.signal);
|
||||||
|
* // Process suppliers...
|
||||||
|
* } catch (error) {
|
||||||
|
* // Handle error...
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @todo Add schema validation for cached data
|
||||||
|
*/
|
||||||
|
async fetchSuppliers(abortSignal?: AbortSignal): Promise<Supplier[]> {
|
||||||
|
this.#logger.debug('Fetching suppliers');
|
||||||
|
|
||||||
|
const cached = await this.#memoryStorage.get(SUPPLIER_STORAGE_KEY); // TODO: Schema Validierung erstellen
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
this.#logger.debug('Returning cached suppliers', () => ({
|
||||||
|
supplierCount: (cached as Supplier[]).length
|
||||||
|
}));
|
||||||
|
return cached as Supplier[];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.info('Fetching assigned stock for suppliers');
|
||||||
|
const assignedStock =
|
||||||
|
await this.#stockService.fetchAssignedStock(abortSignal);
|
||||||
|
|
||||||
|
if (!assignedStock) {
|
||||||
|
const error = new DataAccessError(
|
||||||
|
'NO_ASSIGNED_STOCK_FOUND',
|
||||||
|
'No assigned stock found',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
this.#logger.error('No assigned stock found', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.info('Fetching suppliers from API', () => ({
|
||||||
|
stockId: assignedStock.id
|
||||||
|
}));
|
||||||
|
|
||||||
|
let req$ = this.#supplierService.SupplierGetSuppliers({
|
||||||
|
stockId: assignedStock.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (abortSignal) {
|
||||||
|
this.#logger.debug('Request configured with abort signal');
|
||||||
|
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await firstValueFrom(req$);
|
||||||
|
|
||||||
|
if (res.error || !res.result) {
|
||||||
|
const error = new Error(res.message || 'Failed to fetch Suppliers');
|
||||||
|
this.#logger.error('Failed to fetch suppliers', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.debug('Successfully fetched suppliers', () => ({
|
||||||
|
supplierCount: res.result?.length || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.#memoryStorage.set(SUPPLIER_STORAGE_KEY, res.result);
|
||||||
|
|
||||||
|
return res.result as Supplier[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment
|
|
||||||
globalThis.ngJest = {
|
|
||||||
testEnvironmentOptions: {
|
|
||||||
errorOnUnknownElements: true,
|
|
||||||
errorOnUnknownProperties: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||||
|
|
||||||
setupZoneTestEnv();
|
setupZoneTestEnv({
|
||||||
|
errorOnUnknownElements: true,
|
||||||
|
errorOnUnknownProperties: true,
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { RemissionListItemComponent } from './remission-list-item.component';
|
||||||
|
import { ReturnItem, ReturnSuggestion, StockInfo } from '@isa/remission/data-access';
|
||||||
|
import { ProductInfoComponent, ProductStockInfoComponent } from '@isa/remission/shared/product';
|
||||||
|
import { MockComponent } from 'ng-mocks';
|
||||||
|
|
||||||
|
describe('RemissionListItemComponent', () => {
|
||||||
|
let component: RemissionListItemComponent;
|
||||||
|
let fixture: ComponentFixture<RemissionListItemComponent>;
|
||||||
|
|
||||||
|
const createMockReturnItem = (overrides: Partial<ReturnItem> = {}): ReturnItem => ({
|
||||||
|
id: 1,
|
||||||
|
predefinedReturnQuantity: 5,
|
||||||
|
...overrides,
|
||||||
|
} as ReturnItem);
|
||||||
|
|
||||||
|
const createMockReturnSuggestion = (overrides: Partial<ReturnSuggestion> = {}): ReturnSuggestion => ({
|
||||||
|
id: 1,
|
||||||
|
returnItem: {
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
predefinedReturnQuantity: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
} as ReturnSuggestion);
|
||||||
|
|
||||||
|
const createMockStockInfo = (overrides: Partial<StockInfo> = {}): StockInfo => ({
|
||||||
|
id: 1,
|
||||||
|
quantity: 100,
|
||||||
|
...overrides,
|
||||||
|
} as StockInfo);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
RemissionListItemComponent,
|
||||||
|
MockComponent(ProductInfoComponent),
|
||||||
|
MockComponent(ProductStockInfoComponent),
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(RemissionListItemComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component Setup', () => {
|
||||||
|
it('should create', () => {
|
||||||
|
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have item as required input', () => {
|
||||||
|
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(component.item()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have stock as required input', () => {
|
||||||
|
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(component.stock()).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('predefinedReturnQuantity computed signal', () => {
|
||||||
|
describe('with ReturnItem', () => {
|
||||||
|
it('should return predefinedReturnQuantity when available', () => {
|
||||||
|
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 15 });
|
||||||
|
fixture.componentRef.setInput('item', mockItem);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when predefinedReturnQuantity is null', () => {
|
||||||
|
const mockItem = createMockReturnItem({ predefinedReturnQuantity: null as any });
|
||||||
|
fixture.componentRef.setInput('item', mockItem);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when predefinedReturnQuantity is undefined', () => {
|
||||||
|
const mockItem = createMockReturnItem({ predefinedReturnQuantity: undefined });
|
||||||
|
fixture.componentRef.setInput('item', mockItem);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when predefinedReturnQuantity is 0', () => {
|
||||||
|
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 0 });
|
||||||
|
fixture.componentRef.setInput('item', mockItem);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with ReturnSuggestion', () => {
|
||||||
|
it('should return predefinedReturnQuantity from returnItem.data when available', () => {
|
||||||
|
const mockSuggestion = createMockReturnSuggestion({
|
||||||
|
returnItem: {
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
predefinedReturnQuantity: 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('item', mockSuggestion);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when returnItem.data.predefinedReturnQuantity is null', () => {
|
||||||
|
const mockSuggestion = createMockReturnSuggestion({
|
||||||
|
returnItem: {
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
predefinedReturnQuantity: null as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('item', mockSuggestion);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when returnItem.data.predefinedReturnQuantity is undefined', () => {
|
||||||
|
const mockSuggestion = createMockReturnSuggestion({
|
||||||
|
returnItem: {
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
predefinedReturnQuantity: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('item', mockSuggestion);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when returnItem is null', () => {
|
||||||
|
const mockSuggestion = createMockReturnSuggestion({
|
||||||
|
returnItem: null as any,
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('item', mockSuggestion);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when returnItem.data is null', () => {
|
||||||
|
const mockSuggestion = createMockReturnSuggestion({
|
||||||
|
returnItem: {
|
||||||
|
data: null as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('item', mockSuggestion);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type detection', () => {
|
||||||
|
it('should correctly identify ReturnSuggestion type', () => {
|
||||||
|
const mockSuggestion = createMockReturnSuggestion();
|
||||||
|
fixture.componentRef.setInput('item', mockSuggestion);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const item = component.item();
|
||||||
|
expect('returnItem' in item).toBe(true);
|
||||||
|
expect('predefinedReturnQuantity' in item).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify ReturnItem type', () => {
|
||||||
|
const mockItem = createMockReturnItem();
|
||||||
|
fixture.componentRef.setInput('item', mockItem);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const item = component.item();
|
||||||
|
expect('returnItem' in item).toBe(false);
|
||||||
|
expect('predefinedReturnQuantity' in item).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component reactivity', () => {
|
||||||
|
it('should update predefinedReturnQuantity when input changes from ReturnItem to ReturnSuggestion', () => {
|
||||||
|
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 5 });
|
||||||
|
fixture.componentRef.setInput('item', mockItem);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(5);
|
||||||
|
|
||||||
|
const mockSuggestion = createMockReturnSuggestion({
|
||||||
|
returnItem: {
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
predefinedReturnQuantity: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('item', mockSuggestion);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update predefinedReturnQuantity when input changes from ReturnSuggestion to ReturnItem', () => {
|
||||||
|
const mockSuggestion = createMockReturnSuggestion({
|
||||||
|
returnItem: {
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
predefinedReturnQuantity: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('item', mockSuggestion);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(30);
|
||||||
|
|
||||||
|
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 8 });
|
||||||
|
fixture.componentRef.setInput('item', mockItem);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle negative predefinedReturnQuantity values', () => {
|
||||||
|
const mockItem = createMockReturnItem({ predefinedReturnQuantity: -5 });
|
||||||
|
fixture.componentRef.setInput('item', mockItem);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(-5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large predefinedReturnQuantity values', () => {
|
||||||
|
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 999999 });
|
||||||
|
fixture.componentRef.setInput('item', mockItem);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(999999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal predefinedReturnQuantity values', () => {
|
||||||
|
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 3.5 });
|
||||||
|
fixture.componentRef.setInput('item', mockItem);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(3.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deeply nested null values in ReturnSuggestion', () => {
|
||||||
|
const mockSuggestion = {
|
||||||
|
id: 1,
|
||||||
|
returnItem: {
|
||||||
|
data: null as any,
|
||||||
|
},
|
||||||
|
} as ReturnSuggestion;
|
||||||
|
fixture.componentRef.setInput('item', mockSuggestion);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle item with unexpected structure', () => {
|
||||||
|
const unexpectedItem = {
|
||||||
|
id: 1,
|
||||||
|
// Missing both returnItem and predefinedReturnQuantity
|
||||||
|
} as any;
|
||||||
|
fixture.componentRef.setInput('item', unexpectedItem);
|
||||||
|
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.predefinedReturnQuantity()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,49 +1,49 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
ReturnItem,
|
ReturnItem,
|
||||||
ReturnSuggestion,
|
ReturnSuggestion,
|
||||||
StockInfo,
|
StockInfo,
|
||||||
} from '@isa/remission/data-access';
|
} from '@isa/remission/data-access';
|
||||||
import {
|
import {
|
||||||
ProductInfoComponent,
|
ProductInfoComponent,
|
||||||
ProductStockInfoComponent,
|
ProductStockInfoComponent,
|
||||||
} from '@isa/remission/shared/product';
|
} from '@isa/remission/shared/product';
|
||||||
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'remission-feature-remission-list-item',
|
selector: 'remi-feature-remission-list-item',
|
||||||
templateUrl: './remission-list-item.component.html',
|
templateUrl: './remission-list-item.component.html',
|
||||||
styleUrl: './remission-list-item.component.scss',
|
styleUrl: './remission-list-item.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
ProductInfoComponent,
|
ProductInfoComponent,
|
||||||
ProductStockInfoComponent,
|
ProductStockInfoComponent,
|
||||||
ClientRowImports,
|
ClientRowImports,
|
||||||
ItemRowDataImports,
|
ItemRowDataImports,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class RemissionListItemComponent {
|
export class RemissionListItemComponent {
|
||||||
item = input.required<ReturnItem | ReturnSuggestion>();
|
item = input.required<ReturnItem | ReturnSuggestion>();
|
||||||
stock = input.required<StockInfo>();
|
stock = input.required<StockInfo>();
|
||||||
|
|
||||||
predefinedReturnQuantity = computed(() => {
|
predefinedReturnQuantity = computed(() => {
|
||||||
const item = this.item();
|
const item = this.item();
|
||||||
|
|
||||||
// ReturnSuggestion
|
// ReturnSuggestion
|
||||||
if ('returnItem' in item && item?.returnItem?.data) {
|
if ('returnItem' in item && item?.returnItem?.data) {
|
||||||
return item.returnItem.data.predefinedReturnQuantity ?? 0;
|
return item.returnItem.data.predefinedReturnQuantity ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReturnItem
|
// ReturnItem
|
||||||
if ('predefinedReturnQuantity' in item) {
|
if ('predefinedReturnQuantity' in item) {
|
||||||
return item.predefinedReturnQuantity ?? 0;
|
return item.predefinedReturnQuantity ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<ui-dropdown
|
<ui-dropdown
|
||||||
class="remission-feature-remission-list-select__dropdown"
|
class="remi-feature-remission-list-select__dropdown"
|
||||||
[value]="selectedRemissionListType()"
|
[value]="selectedRemissionListType()"
|
||||||
[appearance]="DropdownAppearance.Grey"
|
[appearance]="DropdownAppearance.Grey"
|
||||||
(valueChange)="changeRemissionType($event)"
|
(valueChange)="changeRemissionType($event)"
|
||||||
data-which="remission-list-select-dropdown"
|
data-which="remission-list-select-dropdown"
|
||||||
[attr.data-what]="`remission-list-selected-value-${selectedRemissionListType()}`"
|
[attr.data-what]="
|
||||||
>
|
`remission-list-selected-value-${selectedRemissionListType()}`
|
||||||
@for (kv of remissionListTypes; track kv.key) {
|
"
|
||||||
<ui-dropdown-option
|
>
|
||||||
[attr.data-what]="`remission-list-option-${kv.value}`"
|
@for (kv of remissionListTypes; track kv.key) {
|
||||||
[disabled]="kv.value === RemissionListCategory.Koerperlos"
|
<ui-dropdown-option
|
||||||
[value]="kv.value"
|
[attr.data-what]="`remission-list-option-${kv.value}`"
|
||||||
>{{ kv.value }}</ui-dropdown-option
|
[disabled]="kv.value === RemissionListCategory.Koerperlos"
|
||||||
>
|
[value]="kv.value"
|
||||||
}
|
>{{ kv.value }}</ui-dropdown-option
|
||||||
</ui-dropdown>
|
>
|
||||||
|
}
|
||||||
|
</ui-dropdown>
|
||||||
|
|||||||
@@ -1,48 +1,48 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
RemissionListType,
|
RemissionListType,
|
||||||
RemissionSearchService,
|
RemissionSearchService,
|
||||||
} from '@isa/remission/data-access';
|
} from '@isa/remission/data-access';
|
||||||
import {
|
import {
|
||||||
DropdownAppearance,
|
DropdownAppearance,
|
||||||
DropdownButtonComponent,
|
DropdownButtonComponent,
|
||||||
DropdownOptionComponent,
|
DropdownOptionComponent,
|
||||||
} from '@isa/ui/input-controls';
|
} from '@isa/ui/input-controls';
|
||||||
import { remissionListTypeRouteMapping } from './remission-list-type-route.mapping';
|
import { remissionListTypeRouteMapping } from './remission-list-type-route.mapping';
|
||||||
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'remission-feature-remission-list-select',
|
selector: 'remi-feature-remission-list-select',
|
||||||
templateUrl: './remission-list-select.component.html',
|
templateUrl: './remission-list-select.component.html',
|
||||||
styleUrl: './remission-list-select.component.scss',
|
styleUrl: './remission-list-select.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [DropdownButtonComponent, DropdownOptionComponent],
|
imports: [DropdownButtonComponent, DropdownOptionComponent],
|
||||||
})
|
})
|
||||||
export class RemissionListSelectComponent {
|
export class RemissionListSelectComponent {
|
||||||
DropdownAppearance = DropdownAppearance;
|
DropdownAppearance = DropdownAppearance;
|
||||||
RemissionListCategory = RemissionListType;
|
RemissionListCategory = RemissionListType;
|
||||||
remissionSearchService = inject(RemissionSearchService);
|
remissionSearchService = inject(RemissionSearchService);
|
||||||
router = inject(Router);
|
router = inject(Router);
|
||||||
route = inject(ActivatedRoute);
|
route = inject(ActivatedRoute);
|
||||||
|
|
||||||
remissionListTypes = this.remissionSearchService.remissionListType();
|
remissionListTypes = this.remissionSearchService.remissionListType();
|
||||||
selectedRemissionListType = injectRemissionListType();
|
selectedRemissionListType = injectRemissionListType();
|
||||||
|
|
||||||
async changeRemissionType(remissionTypeValue: RemissionListType | undefined) {
|
async changeRemissionType(remissionTypeValue: RemissionListType | undefined) {
|
||||||
console.log(remissionTypeValue, remissionListTypeRouteMapping);
|
console.log(remissionTypeValue, remissionListTypeRouteMapping);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!remissionTypeValue ||
|
!remissionTypeValue ||
|
||||||
remissionTypeValue === RemissionListType.Koerperlos
|
remissionTypeValue === RemissionListType.Koerperlos
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await this.router.navigate(
|
await this.router.navigate(
|
||||||
[remissionListTypeRouteMapping[remissionTypeValue]],
|
[remissionListTypeRouteMapping[remissionTypeValue]],
|
||||||
{
|
{
|
||||||
relativeTo: this.route,
|
relativeTo: this.route,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
<remission-feature-remission-start-card></remission-feature-remission-start-card>
|
<remission-feature-remission-start-card></remission-feature-remission-start-card>
|
||||||
|
|
||||||
<remission-feature-remission-list-select></remission-feature-remission-list-select>
|
<remi-feature-remission-list-select></remi-feature-remission-list-select>
|
||||||
|
|
||||||
<filter-controls-panel (triggerSearch)="search()"></filter-controls-panel>
|
<filter-controls-panel (triggerSearch)="search()"></filter-controls-panel>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="text-isa-neutral-900 isa-text-body-2-regular self-start"
|
class="text-isa-neutral-900 isa-text-body-2-regular self-start"
|
||||||
data-what="result-count"
|
data-what="result-count"
|
||||||
>
|
>
|
||||||
{{ hits() }} Einträge
|
{{ hits() }} Einträge
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 w-full items-center justify-center">
|
<div class="flex flex-col gap-4 w-full items-center justify-center">
|
||||||
@for (item of items(); track item.id) {
|
@for (item of items(); track item.id) {
|
||||||
@defer (on viewport) {
|
@defer (on viewport) {
|
||||||
<a [routerLink]="['../', 'return', item.id]" class="w-full">
|
<a [routerLink]="['../', 'return', item.id]" class="w-full">
|
||||||
<remission-feature-remission-list-item
|
<remi-feature-remission-list-item
|
||||||
#listElement
|
#listElement
|
||||||
[item]="item"
|
[item]="item"
|
||||||
[stock]="getStockForItem(item)"
|
[stock]="getStockForItem(item)"
|
||||||
></remission-feature-remission-list-item>
|
></remi-feature-remission-list-item>
|
||||||
</a>
|
</a>
|
||||||
} @placeholder {
|
} @placeholder {
|
||||||
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
||||||
<ui-icon-button
|
<ui-icon-button
|
||||||
[pending]="true"
|
[pending]="true"
|
||||||
[color]="'tertiary'"
|
[color]="'tertiary'"
|
||||||
data-what="load-spinner"
|
data-what="load-spinner"
|
||||||
data-which="item-placeholder"
|
data-which="item-placeholder"
|
||||||
></ui-icon-button>
|
></ui-icon-button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +1,53 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { ResolveFn } from '@angular/router';
|
import { ResolveFn } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
QuerySettings,
|
QuerySettings,
|
||||||
RemissionSearchService,
|
RemissionSearchService,
|
||||||
RemissionStockService,
|
RemissionStockService,
|
||||||
RemissionSupplierService,
|
RemissionSupplierService,
|
||||||
} from '@isa/remission/data-access';
|
} from '@isa/remission/data-access';
|
||||||
|
|
||||||
export const querySettingsDepartmentResolverFn: ResolveFn<
|
/**
|
||||||
QuerySettings
|
* Resolver function that fetches department-specific query settings for the remission list.
|
||||||
> = async () => {
|
* Similar to querySettingsResolverFn but fetches department-specific settings.
|
||||||
const remissionSearchService = inject(RemissionSearchService);
|
*
|
||||||
const remissionStockService = inject(RemissionStockService);
|
* @function querySettingsDepartmentResolverFn
|
||||||
const remissionSupplierService = inject(RemissionSupplierService);
|
* @type {ResolveFn<QuerySettings>}
|
||||||
|
* @returns {Promise<QuerySettings>} The department query settings for the current stock and supplier
|
||||||
const assignedStock = await remissionStockService.fetchAssignedStock();
|
* @throws {Error} When no assigned stock is available
|
||||||
|
* @throws {Error} When no supplier is available
|
||||||
if (!assignedStock?.id) {
|
*
|
||||||
throw new Error('No assigned stock available');
|
* @example
|
||||||
}
|
* // In route configuration
|
||||||
|
* {
|
||||||
const suppliers = await remissionSupplierService.fetchSuppliers({
|
* path: 'remissions/department',
|
||||||
assignedStockId: assignedStock.id,
|
* resolve: { querySettings: querySettingsDepartmentResolverFn },
|
||||||
});
|
* component: RemissionDepartmentListComponent
|
||||||
|
* }
|
||||||
const firstSupplier = suppliers[0]; // Currently only one supplier exists
|
*/
|
||||||
|
export const querySettingsDepartmentResolverFn: ResolveFn<
|
||||||
if (!firstSupplier?.id) {
|
QuerySettings
|
||||||
throw new Error('No Supplier available');
|
> = async () => {
|
||||||
}
|
const remissionSearchService = inject(RemissionSearchService);
|
||||||
|
const remissionStockService = inject(RemissionStockService);
|
||||||
return remissionSearchService.fetchQueryDepartmentSettings({
|
const remissionSupplierService = inject(RemissionSupplierService);
|
||||||
assignedStockId: assignedStock.id,
|
|
||||||
supplierId: firstSupplier.id,
|
const assignedStock = await remissionStockService.fetchAssignedStock();
|
||||||
});
|
|
||||||
};
|
if (!assignedStock?.id) {
|
||||||
|
throw new Error('No assigned stock available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const suppliers = await remissionSupplierService.fetchSuppliers();
|
||||||
|
|
||||||
|
const firstSupplier = suppliers[0]; // Currently only one supplier exists
|
||||||
|
|
||||||
|
if (!firstSupplier?.id) {
|
||||||
|
throw new Error('No Supplier available');
|
||||||
|
}
|
||||||
|
|
||||||
|
return remissionSearchService.fetchQueryDepartmentSettings({
|
||||||
|
assignedStockId: assignedStock.id,
|
||||||
|
supplierId: firstSupplier.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,35 +1,52 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { ResolveFn } from '@angular/router';
|
import { ResolveFn } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
QuerySettings,
|
QuerySettings,
|
||||||
RemissionSearchService,
|
RemissionSearchService,
|
||||||
RemissionStockService,
|
RemissionStockService,
|
||||||
RemissionSupplierService,
|
RemissionSupplierService,
|
||||||
} from '@isa/remission/data-access';
|
} from '@isa/remission/data-access';
|
||||||
|
|
||||||
export const querySettingsResolverFn: ResolveFn<QuerySettings> = async () => {
|
/**
|
||||||
const remissionSearchService = inject(RemissionSearchService);
|
* Resolver function that fetches query settings for the remission list.
|
||||||
const remissionStockService = inject(RemissionStockService);
|
* Retrieves the assigned stock and supplier information, then fetches
|
||||||
const remissionSupplierService = inject(RemissionSupplierService);
|
* the corresponding query settings for filtering and sorting.
|
||||||
|
*
|
||||||
const assignedStock = await remissionStockService.fetchAssignedStock();
|
* @function querySettingsResolverFn
|
||||||
|
* @type {ResolveFn<QuerySettings>}
|
||||||
if (!assignedStock?.id) {
|
* @returns {Promise<QuerySettings>} The query settings for the current stock and supplier
|
||||||
throw new Error('No assigned stock available');
|
* @throws {Error} When no assigned stock is available
|
||||||
}
|
* @throws {Error} When no supplier is available
|
||||||
|
*
|
||||||
const suppliers = await remissionSupplierService.fetchSuppliers({
|
* @example
|
||||||
assignedStockId: assignedStock.id,
|
* // In route configuration
|
||||||
});
|
* {
|
||||||
|
* path: 'remissions',
|
||||||
const firstSupplier = suppliers[0]; // Currently only one supplier exists
|
* resolve: { querySettings: querySettingsResolverFn },
|
||||||
|
* component: RemissionListComponent
|
||||||
if (!firstSupplier?.id) {
|
* }
|
||||||
throw new Error('No Supplier available');
|
*/
|
||||||
}
|
export const querySettingsResolverFn: ResolveFn<QuerySettings> = async () => {
|
||||||
|
const remissionSearchService = inject(RemissionSearchService);
|
||||||
return remissionSearchService.fetchQuerySettings({
|
const remissionStockService = inject(RemissionStockService);
|
||||||
assignedStockId: assignedStock.id,
|
const remissionSupplierService = inject(RemissionSupplierService);
|
||||||
supplierId: firstSupplier.id,
|
|
||||||
});
|
const assignedStock = await remissionStockService.fetchAssignedStock();
|
||||||
};
|
|
||||||
|
if (!assignedStock?.id) {
|
||||||
|
throw new Error('No assigned stock available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const suppliers = await remissionSupplierService.fetchSuppliers();
|
||||||
|
|
||||||
|
const firstSupplier = suppliers[0]; // Currently only one supplier exists
|
||||||
|
|
||||||
|
if (!firstSupplier?.id) {
|
||||||
|
throw new Error('No Supplier available');
|
||||||
|
}
|
||||||
|
|
||||||
|
return remissionSearchService.fetchQuerySettings({
|
||||||
|
assignedStockId: assignedStock.id,
|
||||||
|
supplierId: firstSupplier.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,57 +1,79 @@
|
|||||||
import { inject, resource } from '@angular/core';
|
import { inject, resource } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
RemissionListType,
|
RemissionListType,
|
||||||
RemissionSearchService,
|
RemissionSearchService,
|
||||||
RemissionStockService,
|
RemissionStockService,
|
||||||
RemissionSupplierService,
|
RemissionSupplierService,
|
||||||
} from '@isa/remission/data-access';
|
} from '@isa/remission/data-access';
|
||||||
import { QueryTokenInput } from 'libs/remission/data-access/src/lib/schemas';
|
import { QueryTokenInput } from 'libs/remission/data-access/src/lib/schemas';
|
||||||
|
|
||||||
export const createRemissionListResource = (
|
/**
|
||||||
params: () => {
|
* Creates an Angular resource for fetching remission lists.
|
||||||
remissionListType: RemissionListType;
|
* Handles both standard (Pflicht) and department (Abteilung) remission lists.
|
||||||
queryToken: QueryTokenInput;
|
* Automatically fetches the assigned stock and supplier before loading the list.
|
||||||
},
|
*
|
||||||
) => {
|
* @function createRemissionListResource
|
||||||
const remissionSearchService = inject(RemissionSearchService);
|
* @param {Function} params - Function that returns parameters for the resource
|
||||||
const remissionStockService = inject(RemissionStockService);
|
* @param {RemissionListType} params.remissionListType - Type of remission list to fetch
|
||||||
const remissionSupplierService = inject(RemissionSupplierService);
|
* @param {QueryTokenInput} params.queryToken - Query parameters for filtering and sorting
|
||||||
return resource({
|
* @returns {Resource} Angular resource that manages the remission list data
|
||||||
params,
|
* @throws {Error} When no current stock is available
|
||||||
loader: async ({ abortSignal, params }) => {
|
* @throws {Error} When no supplier is available
|
||||||
const assignedStock = await remissionStockService.fetchAssignedStock();
|
*
|
||||||
|
* @example
|
||||||
if (!assignedStock || !assignedStock.id) {
|
* const remissionListResource = createRemissionListResource(() => ({
|
||||||
throw new Error('No current stock available');
|
* remissionListType: RemissionListType.Pflicht,
|
||||||
}
|
* queryToken: {
|
||||||
|
* filter: { status: 'open' },
|
||||||
const suppliers = await remissionSupplierService.fetchSuppliers({
|
* orderBy: 'date',
|
||||||
assignedStockId: assignedStock.id,
|
* // ... other query parameters
|
||||||
});
|
* }
|
||||||
|
* }));
|
||||||
const firstSupplier = suppliers[0]; // Es gibt aktuell nur Blank als Supplier
|
*/
|
||||||
|
export const createRemissionListResource = (
|
||||||
if (!firstSupplier || !firstSupplier.id) {
|
params: () => {
|
||||||
throw new Error('No Supplier available');
|
remissionListType: RemissionListType;
|
||||||
}
|
queryToken: QueryTokenInput;
|
||||||
|
},
|
||||||
if (params.remissionListType === RemissionListType.Pflicht) {
|
) => {
|
||||||
return await remissionSearchService.fetchList({
|
const remissionSearchService = inject(RemissionSearchService);
|
||||||
assignedStockId: assignedStock.id,
|
const remissionStockService = inject(RemissionStockService);
|
||||||
supplierId: firstSupplier.id,
|
const remissionSupplierService = inject(RemissionSupplierService);
|
||||||
...params.queryToken,
|
return resource({
|
||||||
});
|
params,
|
||||||
}
|
loader: async ({ abortSignal, params }) => {
|
||||||
|
const assignedStock = await remissionStockService.fetchAssignedStock();
|
||||||
if (params.remissionListType === RemissionListType.Abteilung) {
|
|
||||||
return await remissionSearchService.fetchDepartmentList({
|
if (!assignedStock || !assignedStock.id) {
|
||||||
assignedStockId: assignedStock.id,
|
throw new Error('No current stock available');
|
||||||
supplierId: firstSupplier.id,
|
}
|
||||||
...params.queryToken,
|
|
||||||
});
|
const suppliers =
|
||||||
}
|
await remissionSupplierService.fetchSuppliers(abortSignal);
|
||||||
|
|
||||||
return undefined;
|
const firstSupplier = suppliers[0]; // Es gibt aktuell nur Blank als Supplier
|
||||||
},
|
|
||||||
});
|
if (!firstSupplier || !firstSupplier.id) {
|
||||||
};
|
throw new Error('No Supplier available');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.remissionListType === RemissionListType.Pflicht) {
|
||||||
|
return await remissionSearchService.fetchList({
|
||||||
|
assignedStockId: assignedStock.id,
|
||||||
|
supplierId: firstSupplier.id,
|
||||||
|
...params.queryToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.remissionListType === RemissionListType.Abteilung) {
|
||||||
|
return await remissionSearchService.fetchDepartmentList({
|
||||||
|
assignedStockId: assignedStock.id,
|
||||||
|
supplierId: firstSupplier.id,
|
||||||
|
...params.queryToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# remission-feature-remission-return-receipt-details
|
||||||
|
|
||||||
|
This library was generated with [Nx](https://nx.dev).
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `nx test remission-feature-remission-return-receipt-details` to execute the unit tests.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const nx = require('@nx/eslint-plugin');
|
||||||
|
const baseConfig = require('../../../../eslint.config.js');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
...baseConfig,
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'remi',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'remi',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "remission-feature-remission-return-receipt-details",
|
||||||
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "libs/remission/feature/remission-return-receipt-details/src",
|
||||||
|
"prefix": "remi",
|
||||||
|
"projectType": "library",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {
|
||||||
|
"test": {
|
||||||
|
"executor": "@nx/vite:test",
|
||||||
|
"outputs": ["{options.reportsDirectory}"],
|
||||||
|
"options": {
|
||||||
|
"reportsDirectory": "../../../../coverage/libs/remission/feature/remission-return-receipt-details"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './lib/remission-return-receipt-details.component';
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div
|
||||||
|
class="isa-text-body-1-bold"
|
||||||
|
*uiSkeletonLoader="loading(); width: '5rem'; height: '1.5rem'"
|
||||||
|
>
|
||||||
|
{{ status() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>Anzahl Positionen</div>
|
||||||
|
<div
|
||||||
|
class="isa-text-body-1-bold"
|
||||||
|
*uiSkeletonLoader="loading(); height: '1.5rem'"
|
||||||
|
>
|
||||||
|
{{ itemCount() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>Lieferant</div>
|
||||||
|
<div
|
||||||
|
class="isa-text-body-1-bold"
|
||||||
|
*uiSkeletonLoader="loading(); width: '5rem'; height: '1.5rem'"
|
||||||
|
>
|
||||||
|
{{ supplier() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>Remissionsdatum</div>
|
||||||
|
<div
|
||||||
|
class="isa-text-body-1-bold"
|
||||||
|
*uiSkeletonLoader="loading(); width: '12rem'; height: '1.5rem'"
|
||||||
|
>
|
||||||
|
{{ remiDate() | date: 'dd.MM.yy' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>Wannennummer</div>
|
||||||
|
<div
|
||||||
|
class="isa-text-body-1-bold"
|
||||||
|
*uiSkeletonLoader="loading(); width: '15rem'; height: '1.5rem'"
|
||||||
|
>
|
||||||
|
{{ packageNumber() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
@apply p-6 bg-isa-neutral-400 rounded-2xl grid grid-cols-3 gap-6 isa-text-body-1-regular;
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
|
||||||
|
import { Receipt, Supplier } from '@isa/remission/data-access';
|
||||||
|
|
||||||
|
// Mock the supplier resource
|
||||||
|
vi.mock('./resources', () => ({
|
||||||
|
createSupplierResource: vi.fn(() => ({
|
||||||
|
value: signal([]),
|
||||||
|
isLoading: signal(false),
|
||||||
|
error: signal(null),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RemissionReturnReceiptDetailsCardComponent', () => {
|
||||||
|
let component: RemissionReturnReceiptDetailsCardComponent;
|
||||||
|
let fixture: ComponentFixture<RemissionReturnReceiptDetailsCardComponent>;
|
||||||
|
|
||||||
|
const mockSuppliers: Supplier[] = [
|
||||||
|
{
|
||||||
|
id: 123,
|
||||||
|
name: 'Test Supplier GmbH',
|
||||||
|
address: 'Test Street 1',
|
||||||
|
} as Supplier,
|
||||||
|
{
|
||||||
|
id: 456,
|
||||||
|
name: 'Another Supplier Ltd',
|
||||||
|
address: 'Another Street 2',
|
||||||
|
} as Supplier,
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockReceipt: Receipt = {
|
||||||
|
id: 789,
|
||||||
|
receiptNumber: 'RR-2024-001234-ABC',
|
||||||
|
completed: true,
|
||||||
|
created: new Date('2024-01-15T10:30:00Z'),
|
||||||
|
supplier: {
|
||||||
|
id: 123,
|
||||||
|
name: 'Test Supplier GmbH',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
quantity: 5,
|
||||||
|
product: { id: 1, name: 'Product 1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
quantity: 3,
|
||||||
|
product: { id: 2, name: 'Product 2' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
packageNumber: 'PKG-001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
packageNumber: 'PKG-002',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Receipt;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [RemissionReturnReceiptDetailsCardComponent, DatePipe],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(RemissionReturnReceiptDetailsCardComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component Setup', () => {
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have default loading state', () => {
|
||||||
|
expect(component.loading()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept receipt input', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', mockReceipt);
|
||||||
|
expect(component.receipt()).toEqual(mockReceipt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept loading input', () => {
|
||||||
|
fixture.componentRef.setInput('loading', false);
|
||||||
|
expect(component.loading()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('status computed signal', () => {
|
||||||
|
it('should return "Abgeschlossen" when receipt is completed', () => {
|
||||||
|
const completedReceipt = { ...mockReceipt, completed: true };
|
||||||
|
fixture.componentRef.setInput('receipt', completedReceipt);
|
||||||
|
|
||||||
|
expect(component.status()).toBe('Abgeschlossen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "Offen" when receipt is not completed', () => {
|
||||||
|
const openReceipt = { ...mockReceipt, completed: false };
|
||||||
|
fixture.componentRef.setInput('receipt', openReceipt);
|
||||||
|
|
||||||
|
expect(component.status()).toBe('Offen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "Offen" when no receipt provided', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', undefined);
|
||||||
|
|
||||||
|
expect(component.status()).toBe('Offen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('itemCount computed signal', () => {
|
||||||
|
it('should calculate total quantity from items', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', mockReceipt);
|
||||||
|
|
||||||
|
// mockReceipt has items with quantities 5 and 3 = 8 total
|
||||||
|
expect(component.itemCount()).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when no items', () => {
|
||||||
|
const receiptWithoutItems = { ...mockReceipt, items: [] };
|
||||||
|
fixture.componentRef.setInput('receipt', receiptWithoutItems);
|
||||||
|
|
||||||
|
expect(component.itemCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when no receipt provided', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', undefined);
|
||||||
|
|
||||||
|
expect(component.itemCount()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle items with undefined data', () => {
|
||||||
|
const receiptWithUndefinedItems = {
|
||||||
|
...mockReceipt,
|
||||||
|
items: [
|
||||||
|
{ id: 1, data: undefined },
|
||||||
|
{ id: 2, data: { id: 2, quantity: 5 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
fixture.componentRef.setInput('receipt', receiptWithUndefinedItems);
|
||||||
|
|
||||||
|
expect(component.itemCount()).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('supplier computed signal', () => {
|
||||||
|
it('should return supplier name when found', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', mockReceipt);
|
||||||
|
(component.supplierResource as any).value = signal(mockSuppliers);
|
||||||
|
|
||||||
|
expect(component.supplier()).toBe('Test Supplier GmbH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "Unbekannt" when supplier not found', () => {
|
||||||
|
const receiptWithUnknownSupplier = {
|
||||||
|
...mockReceipt,
|
||||||
|
supplier: { id: 999, name: 'Unknown' },
|
||||||
|
};
|
||||||
|
fixture.componentRef.setInput('receipt', receiptWithUnknownSupplier);
|
||||||
|
(component.supplierResource as any).value = signal(mockSuppliers);
|
||||||
|
|
||||||
|
expect(component.supplier()).toBe('Unbekannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "Unbekannt" when no suppliers loaded', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', mockReceipt);
|
||||||
|
(component.supplierResource as any).value = signal([]);
|
||||||
|
|
||||||
|
expect(component.supplier()).toBe('Unbekannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "Unbekannt" when no receipt provided', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', undefined);
|
||||||
|
(component.supplierResource as any).value = signal(mockSuppliers);
|
||||||
|
|
||||||
|
expect(component.supplier()).toBe('Unbekannt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('completedAt computed signal', () => {
|
||||||
|
it('should return created date', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', mockReceipt);
|
||||||
|
|
||||||
|
expect(component.completedAt()).toEqual(mockReceipt.created);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when no receipt', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', undefined);
|
||||||
|
|
||||||
|
expect(component.completedAt()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remiDate computed signal', () => {
|
||||||
|
it('should return completed date when available', () => {
|
||||||
|
const completedDate = new Date('2024-01-20T15:45:00Z');
|
||||||
|
const receiptWithCompleted = {
|
||||||
|
...mockReceipt,
|
||||||
|
completed: completedDate,
|
||||||
|
created: new Date('2024-01-15T10:30:00Z'),
|
||||||
|
};
|
||||||
|
fixture.componentRef.setInput('receipt', receiptWithCompleted);
|
||||||
|
|
||||||
|
expect(component.remiDate()).toEqual(completedDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return created date when completed date not available', () => {
|
||||||
|
const receiptWithoutCompleted = {
|
||||||
|
...mockReceipt,
|
||||||
|
completed: false,
|
||||||
|
};
|
||||||
|
fixture.componentRef.setInput('receipt', receiptWithoutCompleted);
|
||||||
|
|
||||||
|
expect(component.remiDate()).toEqual(mockReceipt.created);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when no receipt', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', undefined);
|
||||||
|
|
||||||
|
expect(component.remiDate()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('packageNumber computed signal', () => {
|
||||||
|
it('should return comma-separated package numbers', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', mockReceipt);
|
||||||
|
|
||||||
|
expect(component.packageNumber()).toBe('PKG-001, PKG-002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string when no packages', () => {
|
||||||
|
const receiptWithoutPackages = { ...mockReceipt, packages: [] };
|
||||||
|
fixture.componentRef.setInput('receipt', receiptWithoutPackages);
|
||||||
|
|
||||||
|
expect(component.packageNumber()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string when no receipt', () => {
|
||||||
|
fixture.componentRef.setInput('receipt', undefined);
|
||||||
|
|
||||||
|
expect(component.packageNumber()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle packages with undefined data', () => {
|
||||||
|
const receiptWithUndefinedPackages = {
|
||||||
|
...mockReceipt,
|
||||||
|
packages: [
|
||||||
|
{ id: 1, data: undefined },
|
||||||
|
{ id: 2, data: { id: 2, packageNumber: 'PKG-002' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
fixture.componentRef.setInput('receipt', receiptWithUndefinedPackages);
|
||||||
|
|
||||||
|
// packageNumber maps undefined values, which join as ', PKG-002'
|
||||||
|
expect(component.packageNumber()).toBe(', PKG-002');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component reactivity', () => {
|
||||||
|
it('should update computed signals when receipt changes', () => {
|
||||||
|
// Initial receipt
|
||||||
|
fixture.componentRef.setInput('receipt', mockReceipt);
|
||||||
|
(component.supplierResource as any).value = signal(mockSuppliers);
|
||||||
|
|
||||||
|
expect(component.status()).toBe('Abgeschlossen');
|
||||||
|
expect(component.itemCount()).toBe(8);
|
||||||
|
|
||||||
|
// Change receipt
|
||||||
|
const newReceipt = {
|
||||||
|
...mockReceipt,
|
||||||
|
completed: false,
|
||||||
|
items: [{ id: 1, data: { id: 1, quantity: 10 } }],
|
||||||
|
};
|
||||||
|
fixture.componentRef.setInput('receipt', newReceipt);
|
||||||
|
|
||||||
|
expect(component.status()).toBe('Offen');
|
||||||
|
expect(component.itemCount()).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create supplier resource on initialization', () => {
|
||||||
|
expect(component.supplierResource).toBeDefined();
|
||||||
|
expect(component.supplierResource.value).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Receipt } from '@isa/remission/data-access';
|
||||||
|
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
|
||||||
|
import { createSupplierResource } from './resources';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays detailed information about a remission return receipt in a card format.
|
||||||
|
* Shows supplier information, status, dates, item counts, and package numbers.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @selector remi-remission-return-receipt-details-card
|
||||||
|
* @standalone
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <remi-remission-return-receipt-details-card
|
||||||
|
* [receipt]="receiptData"
|
||||||
|
* [loading]="isLoading">
|
||||||
|
* </remi-remission-return-receipt-details-card>
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'remi-remission-return-receipt-details-card',
|
||||||
|
templateUrl: './remission-return-receipt-details-card.component.html',
|
||||||
|
styleUrls: ['./remission-return-receipt-details-card.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [SkeletonLoaderDirective, DatePipe],
|
||||||
|
})
|
||||||
|
export class RemissionReturnReceiptDetailsCardComponent {
|
||||||
|
/**
|
||||||
|
* Input for the receipt data to display.
|
||||||
|
* @input
|
||||||
|
*/
|
||||||
|
receipt = input<Receipt>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input to control the loading state of the card.
|
||||||
|
* @input
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
loading = input(true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource for fetching supplier data.
|
||||||
|
*/
|
||||||
|
supplierResource = createSupplierResource();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal that determines the receipt status text.
|
||||||
|
* @returns {'Abgeschlossen' | 'Offen'} Status text based on completion state
|
||||||
|
*/
|
||||||
|
status = computed(() => {
|
||||||
|
return this.receipt()?.completed ? 'Abgeschlossen' : 'Offen';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal that calculates the total quantity of all items in the receipt.
|
||||||
|
* @returns {number} Sum of all item quantities
|
||||||
|
*/
|
||||||
|
itemCount = computed(() => {
|
||||||
|
const receipt = this.receipt();
|
||||||
|
return receipt?.items?.reduce(
|
||||||
|
(acc, item) => acc + (item.data?.quantity || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal that finds and returns the supplier name.
|
||||||
|
* @returns {string} Supplier name or 'Unbekannt' if not found
|
||||||
|
*/
|
||||||
|
supplier = computed(() => {
|
||||||
|
const receipt = this.receipt();
|
||||||
|
const supplier = this.supplierResource.value();
|
||||||
|
|
||||||
|
return (
|
||||||
|
supplier?.find((s) => s.id === receipt?.supplier?.id)?.name || 'Unbekannt'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal for the receipt completion date.
|
||||||
|
* @returns {Date | undefined} The creation date of the receipt
|
||||||
|
*/
|
||||||
|
completedAt = computed(() => {
|
||||||
|
const receipt = this.receipt();
|
||||||
|
return receipt?.created;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal for the remission date.
|
||||||
|
* Prioritizes completed date over created date.
|
||||||
|
* @returns {Date | undefined} The remission date
|
||||||
|
*/
|
||||||
|
remiDate = computed(() => {
|
||||||
|
const receipt = this.receipt();
|
||||||
|
return receipt?.completed || receipt?.created;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal that concatenates all package numbers.
|
||||||
|
* @returns {string} Comma-separated list of package numbers
|
||||||
|
*/
|
||||||
|
packageNumber = computed(() => {
|
||||||
|
const receipt = this.receipt();
|
||||||
|
return (
|
||||||
|
receipt?.packages?.map((p) => p.data?.packageNumber).join(', ') || ''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<div>
|
||||||
|
<img
|
||||||
|
class="w-full max-h-[5.125rem] object-contain"
|
||||||
|
sharedProductRouterLink
|
||||||
|
sharedProductImage
|
||||||
|
[ean]="item().product.ean"
|
||||||
|
[alt]="item().product.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-flow-row gap-2">
|
||||||
|
<div class="isa-text-body-2-bold">{{ item().product.contributors }}</div>
|
||||||
|
<div class="isa-text-body-2-regular">{{ item().product.name }}</div>
|
||||||
|
<shared-product-format
|
||||||
|
[format]="item().product.format"
|
||||||
|
[formatDetail]="item().product.formatDetail"
|
||||||
|
></shared-product-format>
|
||||||
|
</div>
|
||||||
|
<ui-bullet-list>
|
||||||
|
<ui-bullet-list-item>
|
||||||
|
{{ item().product.productGroup }}:{{ productGroupDetail() }}
|
||||||
|
</ui-bullet-list-item>
|
||||||
|
<ui-bullet-list-item> Remi Menge: {{ item().quantity }} </ui-bullet-list-item>
|
||||||
|
</ui-bullet-list>
|
||||||
|
<div class="flex justify-center items-center">
|
||||||
|
@if (removeable()) {
|
||||||
|
<ui-icon-button
|
||||||
|
[name]="'isaActionClose'"
|
||||||
|
[size]="'large'"
|
||||||
|
[color]="'secondary'"
|
||||||
|
></ui-icon-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
@apply grid grid-cols-[3.5rem,15.5rem,1fr,auto] gap-6 p-4 text-isa-neutral-900;
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { MockComponent, MockDirective, MockProvider } from 'ng-mocks';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
|
||||||
|
import { ProductFormatComponent } from '@isa/shared/product-foramt';
|
||||||
|
import { ProductImageDirective } from '@isa/shared/product-image';
|
||||||
|
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
|
||||||
|
import {
|
||||||
|
ReceiptItem,
|
||||||
|
RemissionProductGroupService,
|
||||||
|
} from '@isa/remission/data-access';
|
||||||
|
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||||
|
import {
|
||||||
|
BulletListComponent,
|
||||||
|
BulletListItemComponent,
|
||||||
|
} from '@isa/ui/bullet-list';
|
||||||
|
|
||||||
|
describe('RemissionReturnReceiptDetailsItemComponent', () => {
|
||||||
|
let component: RemissionReturnReceiptDetailsItemComponent;
|
||||||
|
let fixture: ComponentFixture<RemissionReturnReceiptDetailsItemComponent>;
|
||||||
|
|
||||||
|
const mockReceiptItem: ReceiptItem = {
|
||||||
|
id: 1,
|
||||||
|
quantity: 5,
|
||||||
|
product: {
|
||||||
|
id: 123,
|
||||||
|
name: 'Test Product',
|
||||||
|
contributors: 'Test Author',
|
||||||
|
ean: '1234567890123',
|
||||||
|
format: 'Hardcover',
|
||||||
|
formatDetail: '200 pages',
|
||||||
|
productGroup: 'BOOK',
|
||||||
|
},
|
||||||
|
} as ReceiptItem;
|
||||||
|
|
||||||
|
const mockProductGroups = [
|
||||||
|
{ key: 'BOOK', value: 'Books' },
|
||||||
|
{ key: 'MAGAZINE', value: 'Magazines' },
|
||||||
|
{ key: 'DVD', value: 'DVDs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockRemissionProductGroupService = {
|
||||||
|
fetchProductGroups: vi.fn().mockResolvedValue(mockProductGroups),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [RemissionReturnReceiptDetailsItemComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: RemissionProductGroupService,
|
||||||
|
useValue: mockRemissionProductGroupService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideComponent(RemissionReturnReceiptDetailsItemComponent, {
|
||||||
|
remove: {
|
||||||
|
imports: [
|
||||||
|
ProductImageDirective,
|
||||||
|
ProductRouterLinkDirective,
|
||||||
|
ProductFormatComponent,
|
||||||
|
IconButtonComponent,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
add: {
|
||||||
|
imports: [
|
||||||
|
MockDirective(ProductImageDirective),
|
||||||
|
MockDirective(ProductRouterLinkDirective),
|
||||||
|
MockComponent(ProductFormatComponent),
|
||||||
|
MockComponent(IconButtonComponent),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(
|
||||||
|
RemissionReturnReceiptDetailsItemComponent,
|
||||||
|
);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component Setup', () => {
|
||||||
|
it('should create', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have required item input', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
|
||||||
|
expect(component.item()).toEqual(mockReceiptItem);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component with valid receipt item', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display receipt item data', () => {
|
||||||
|
expect(component.item()).toEqual(mockReceiptItem);
|
||||||
|
expect(component.item().id).toBe(1);
|
||||||
|
expect(component.item().quantity).toBe(5);
|
||||||
|
expect(component.item().product.name).toBe('Test Product');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle product information correctly', () => {
|
||||||
|
const item = component.item();
|
||||||
|
|
||||||
|
expect(item.product.name).toBe('Test Product');
|
||||||
|
expect(item.product.contributors).toBe('Test Author');
|
||||||
|
expect(item.product.ean).toBe('1234567890123');
|
||||||
|
expect(item.product.format).toBe('Hardcover');
|
||||||
|
expect(item.product.formatDetail).toBe('200 pages');
|
||||||
|
expect(item.product.productGroup).toBe('BOOK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle quantity correctly', () => {
|
||||||
|
expect(component.item().quantity).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have default removeable value', () => {
|
||||||
|
expect(component.removeable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component with different receipt item data', () => {
|
||||||
|
it('should handle different quantity values', () => {
|
||||||
|
const differentItem = {
|
||||||
|
...mockReceiptItem,
|
||||||
|
quantity: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', differentItem);
|
||||||
|
|
||||||
|
expect(component.item().quantity).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different product information', () => {
|
||||||
|
const differentItem: ReceiptItem = {
|
||||||
|
...mockReceiptItem,
|
||||||
|
product: {
|
||||||
|
...mockReceiptItem.product,
|
||||||
|
name: 'Different Product',
|
||||||
|
contributors: 'Different Author',
|
||||||
|
productGroup: 'MAGAZINE',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', differentItem);
|
||||||
|
|
||||||
|
expect(component.item().product.name).toBe('Different Product');
|
||||||
|
expect(component.item().product.contributors).toBe('Different Author');
|
||||||
|
expect(component.item().product.productGroup).toBe('MAGAZINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle item with different ID', () => {
|
||||||
|
const differentItem = {
|
||||||
|
...mockReceiptItem,
|
||||||
|
id: 999,
|
||||||
|
};
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', differentItem);
|
||||||
|
|
||||||
|
expect(component.item().id).toBe(999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component reactivity', () => {
|
||||||
|
it('should update when item input changes', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
|
||||||
|
expect(component.item().quantity).toBe(5);
|
||||||
|
expect(component.item().product.name).toBe('Test Product');
|
||||||
|
|
||||||
|
// Change the item
|
||||||
|
const newItem = {
|
||||||
|
...mockReceiptItem,
|
||||||
|
id: 2,
|
||||||
|
quantity: 3,
|
||||||
|
product: {
|
||||||
|
...mockReceiptItem.product,
|
||||||
|
name: 'Updated Product',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', newItem);
|
||||||
|
|
||||||
|
expect(component.item().id).toBe(2);
|
||||||
|
expect(component.item().quantity).toBe(3);
|
||||||
|
expect(component.item().product.name).toBe('Updated Product');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Removeable input', () => {
|
||||||
|
it('should default to false when not provided', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
|
||||||
|
expect(component.removeable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept true value', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
fixture.componentRef.setInput('removeable', true);
|
||||||
|
|
||||||
|
expect(component.removeable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept false value', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
fixture.componentRef.setInput('removeable', false);
|
||||||
|
|
||||||
|
expect(component.removeable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Product Group functionality', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize productGroupResource', () => {
|
||||||
|
expect(component.productGroupResource).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compute productGroupDetail correctly when resource has data', () => {
|
||||||
|
// Mock the resource value directly
|
||||||
|
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
|
||||||
|
mockProductGroups,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The productGroupDetail should find the matching product group
|
||||||
|
expect(component.productGroupDetail()).toBe('Books');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string when resource value is undefined', () => {
|
||||||
|
// Mock the resource to return undefined
|
||||||
|
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(component.productGroupDetail()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string when product group not found', () => {
|
||||||
|
const differentItem: ReceiptItem = {
|
||||||
|
...mockReceiptItem,
|
||||||
|
product: {
|
||||||
|
...mockReceiptItem.product,
|
||||||
|
productGroup: 'UNKNOWN',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', differentItem);
|
||||||
|
|
||||||
|
// Mock the resource value
|
||||||
|
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
|
||||||
|
mockProductGroups,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(component.productGroupDetail()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Icon button rendering', () => {
|
||||||
|
it('should render icon button when removeable is true', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
fixture.componentRef.setInput('removeable', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const iconButton = fixture.nativeElement.querySelector('ui-icon-button');
|
||||||
|
expect(iconButton).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render icon button when removeable is false', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
fixture.componentRef.setInput('removeable', false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const iconButton = fixture.nativeElement.querySelector('ui-icon-button');
|
||||||
|
expect(iconButton).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render icon button with correct properties', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
fixture.componentRef.setInput('removeable', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const iconButton = fixture.debugElement.query(
|
||||||
|
By.css('ui-icon-button'),
|
||||||
|
)?.componentInstance;
|
||||||
|
|
||||||
|
expect(iconButton).toBeTruthy();
|
||||||
|
expect(iconButton.name).toBe('isaActionClose');
|
||||||
|
expect(iconButton.size).toBe('large');
|
||||||
|
expect(iconButton.color).toBe('secondary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Template rendering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render product image with correct attributes', () => {
|
||||||
|
const img = fixture.nativeElement.querySelector('img');
|
||||||
|
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.getAttribute('alt')).toBe('Test Product');
|
||||||
|
expect(img.classList.contains('w-full')).toBe(true);
|
||||||
|
expect(img.classList.contains('max-h-[5.125rem]')).toBe(true);
|
||||||
|
expect(img.classList.contains('object-contain')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render product contributors', () => {
|
||||||
|
const contributorsElement = fixture.nativeElement.querySelector(
|
||||||
|
'.isa-text-body-2-bold',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(contributorsElement).toBeTruthy();
|
||||||
|
expect(contributorsElement.textContent).toBe('Test Author');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render product name', () => {
|
||||||
|
const nameElement = fixture.nativeElement.querySelector(
|
||||||
|
'.isa-text-body-2-regular',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nameElement).toBeTruthy();
|
||||||
|
expect(nameElement.textContent).toBe('Test Product');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render bullet list items', () => {
|
||||||
|
const bulletListItems = fixture.nativeElement.querySelectorAll(
|
||||||
|
'ui-bullet-list-item',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bulletListItems.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component imports', () => {
|
||||||
|
it('should have ProductImageDirective import', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
|
||||||
|
// Component should be created successfully with mocked imports
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have ProductRouterLinkDirective import', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
|
||||||
|
// Component should be created successfully with mocked imports
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have ProductFormatComponent import', () => {
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
|
||||||
|
// Component should be created successfully with mocked imports
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('E2E Testing Attributes', () => {
|
||||||
|
it('should consider adding data-what and data-which attributes for E2E testing', () => {
|
||||||
|
// This test serves as a reminder that E2E testing attributes
|
||||||
|
// should be added to the template for better testability.
|
||||||
|
// Currently the template does not have these attributes.
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('item', mockReceiptItem);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const hostElement = fixture.nativeElement;
|
||||||
|
|
||||||
|
// Verify the component renders (basic check)
|
||||||
|
expect(hostElement).toBeTruthy();
|
||||||
|
|
||||||
|
// Note: In a future update, the template should include:
|
||||||
|
// - data-what="receipt-item" on the host or main container
|
||||||
|
// - data-which="receipt-item-details"
|
||||||
|
// - [attr.data-item-id]="item().id" for dynamic identification
|
||||||
|
// This would improve E2E test reliability and maintainability
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ReceiptItem } from '@isa/remission/data-access';
|
||||||
|
import { ProductFormatComponent } from '@isa/shared/product-foramt';
|
||||||
|
import { ProductImageDirective } from '@isa/shared/product-image';
|
||||||
|
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
|
||||||
|
import { UiBulletList } from '@isa/ui/bullet-list';
|
||||||
|
import { productGroupResource } from './resources';
|
||||||
|
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||||
|
import { provideIcons } from '@ng-icons/core';
|
||||||
|
import { isaActionClose } from '@isa/icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for displaying a single receipt item within the remission return receipt details.
|
||||||
|
* Shows product information including image, name, and formatted product details.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @selector remi-remission-return-receipt-details-item
|
||||||
|
* @standalone
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <remi-remission-return-receipt-details-item
|
||||||
|
* [item]="receiptItem">
|
||||||
|
* </remi-remission-return-receipt-details-item>
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'remi-remission-return-receipt-details-item',
|
||||||
|
templateUrl: './remission-return-receipt-details-item.component.html',
|
||||||
|
styleUrls: ['./remission-return-receipt-details-item.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
ProductImageDirective,
|
||||||
|
ProductRouterLinkDirective,
|
||||||
|
ProductFormatComponent,
|
||||||
|
UiBulletList,
|
||||||
|
IconButtonComponent,
|
||||||
|
],
|
||||||
|
providers: [provideIcons({ isaActionClose })],
|
||||||
|
})
|
||||||
|
export class RemissionReturnReceiptDetailsItemComponent {
|
||||||
|
/**
|
||||||
|
* Required input for the receipt item to display.
|
||||||
|
* Contains product information and quantity details.
|
||||||
|
* @input
|
||||||
|
* @required
|
||||||
|
*/
|
||||||
|
item = input.required<ReceiptItem>();
|
||||||
|
|
||||||
|
removeable = input<boolean>(false);
|
||||||
|
|
||||||
|
productGroupResource = productGroupResource();
|
||||||
|
|
||||||
|
productGroupDetail = computed(() => {
|
||||||
|
const value = this.productGroupResource.value();
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
value.find((group) => group.key === this.item().product.productGroup)
|
||||||
|
?.value || ''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
uiButton
|
||||||
|
[color]="'tertiary'"
|
||||||
|
size="small"
|
||||||
|
class="px-[0.875rem] py-1 min-w-0 bg-white gap-1"
|
||||||
|
(click)="location.back()"
|
||||||
|
>
|
||||||
|
<ng-icon name="isaActionChevronLeft"></ng-icon>
|
||||||
|
zurück
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-center isa-text-subtitle-1-regular">Warenbegleitschein</h1>
|
||||||
|
<h2 class="text-center isa-text-subtitle-1-bold">#{{ receiptNumber() }}</h2>
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
<remi-remission-return-receipt-details-card
|
||||||
|
[receipt]="returnResource.value()"
|
||||||
|
[loading]="returnResource.isLoading()"
|
||||||
|
></remi-remission-return-receipt-details-card>
|
||||||
|
|
||||||
|
@let items = returnResource.value()?.items;
|
||||||
|
|
||||||
|
@if (returnResource.isLoading()) {
|
||||||
|
<div class="text-center">
|
||||||
|
<ui-icon-button
|
||||||
|
class="animate-spin"
|
||||||
|
name="isaLoading"
|
||||||
|
size="large"
|
||||||
|
color="neutral"
|
||||||
|
></ui-icon-button>
|
||||||
|
</div>
|
||||||
|
} @else if (items.length === 0) {
|
||||||
|
<div class="isa-text-body-2-regular">
|
||||||
|
Keine Artikel auf dem Warenbegleitschein.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="bg-isa-white rounded-2xl p-6 grid grid-flow-row gap-6">
|
||||||
|
@for (item of items; track item.id; let last = $last) {
|
||||||
|
<remi-remission-return-receipt-details-item
|
||||||
|
[item]="item.data"
|
||||||
|
[removeable]="canRemoveItems()"
|
||||||
|
></remi-remission-return-receipt-details-item>
|
||||||
|
@if (!last) {
|
||||||
|
<hr class="border-isa-neutral-300" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
@apply grid grid-flow-row gap-4 p-4 text-isa-neutral-900;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { MockComponent, MockProvider } from 'ng-mocks';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { RemissionReturnReceiptDetailsComponent } from './remission-return-receipt-details.component';
|
||||||
|
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
|
||||||
|
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
|
||||||
|
import { Receipt } from '@isa/remission/data-access';
|
||||||
|
|
||||||
|
// Mock the resource function
|
||||||
|
vi.mock('./resources/remission-return-receipt.resource', () => ({
|
||||||
|
createRemissionReturnReceiptResource: vi.fn(() => ({
|
||||||
|
value: signal(null),
|
||||||
|
isLoading: signal(false),
|
||||||
|
error: signal(null),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RemissionReturnReceiptDetailsComponent', () => {
|
||||||
|
let component: RemissionReturnReceiptDetailsComponent;
|
||||||
|
let fixture: ComponentFixture<RemissionReturnReceiptDetailsComponent>;
|
||||||
|
|
||||||
|
const mockReceipt: Receipt = {
|
||||||
|
id: 123,
|
||||||
|
receiptNumber: 'RR-2024-001234-ABC',
|
||||||
|
items: [],
|
||||||
|
completed: true,
|
||||||
|
created: new Date('2024-01-15T10:30:00Z'),
|
||||||
|
} as Receipt;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [RemissionReturnReceiptDetailsComponent],
|
||||||
|
providers: [
|
||||||
|
MockProvider(Location, { back: vi.fn() }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideComponent(RemissionReturnReceiptDetailsComponent, {
|
||||||
|
remove: {
|
||||||
|
imports: [
|
||||||
|
RemissionReturnReceiptDetailsCardComponent,
|
||||||
|
RemissionReturnReceiptDetailsItemComponent,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
add: {
|
||||||
|
imports: [
|
||||||
|
MockComponent(RemissionReturnReceiptDetailsCardComponent),
|
||||||
|
MockComponent(RemissionReturnReceiptDetailsItemComponent),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(RemissionReturnReceiptDetailsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component Setup', () => {
|
||||||
|
it('should create', () => {
|
||||||
|
fixture.componentRef.setInput('returnId', 123);
|
||||||
|
fixture.componentRef.setInput('receiptId', 456);
|
||||||
|
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have required inputs', () => {
|
||||||
|
fixture.componentRef.setInput('returnId', 123);
|
||||||
|
fixture.componentRef.setInput('receiptId', 456);
|
||||||
|
|
||||||
|
expect(component.returnId()).toBe(123);
|
||||||
|
expect(component.receiptId()).toBe(456);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should coerce string inputs to numbers', () => {
|
||||||
|
fixture.componentRef.setInput('returnId', '123');
|
||||||
|
fixture.componentRef.setInput('receiptId', '456');
|
||||||
|
|
||||||
|
expect(component.returnId()).toBe(123);
|
||||||
|
expect(component.receiptId()).toBe(456);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dependencies', () => {
|
||||||
|
it('should inject Location service', () => {
|
||||||
|
fixture.componentRef.setInput('returnId', 123);
|
||||||
|
fixture.componentRef.setInput('receiptId', 456);
|
||||||
|
|
||||||
|
expect(component.location).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create return resource', () => {
|
||||||
|
fixture.componentRef.setInput('returnId', 123);
|
||||||
|
fixture.componentRef.setInput('receiptId', 456);
|
||||||
|
|
||||||
|
expect(component.returnResource).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('receiptNumber computed signal', () => {
|
||||||
|
it('should return empty string when no receipt data', () => {
|
||||||
|
fixture.componentRef.setInput('returnId', 123);
|
||||||
|
fixture.componentRef.setInput('receiptId', 456);
|
||||||
|
|
||||||
|
// Mock empty resource
|
||||||
|
(component.returnResource as any).value = signal(null);
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract receipt number substring correctly', () => {
|
||||||
|
fixture.componentRef.setInput('returnId', 123);
|
||||||
|
fixture.componentRef.setInput('receiptId', 456);
|
||||||
|
|
||||||
|
// Mock resource with receipt data
|
||||||
|
(component.returnResource as any).value = signal(mockReceipt);
|
||||||
|
|
||||||
|
// substring(6, 12) on 'RR-2024-001234-ABC' should return '4-0012'
|
||||||
|
expect(component.receiptNumber()).toBe('4-0012');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined receipt number', () => {
|
||||||
|
const receiptWithoutNumber = {
|
||||||
|
...mockReceipt,
|
||||||
|
receiptNumber: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('returnId', 123);
|
||||||
|
fixture.componentRef.setInput('receiptId', 456);
|
||||||
|
|
||||||
|
(component.returnResource as any).value = signal(receiptWithoutNumber);
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource reactivity', () => {
|
||||||
|
it('should handle resource loading state', () => {
|
||||||
|
fixture.componentRef.setInput('returnId', 123);
|
||||||
|
fixture.componentRef.setInput('receiptId', 456);
|
||||||
|
|
||||||
|
// Mock loading resource
|
||||||
|
(component.returnResource as any).isLoading = signal(true);
|
||||||
|
|
||||||
|
expect(component.returnResource.isLoading()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle resource with data', () => {
|
||||||
|
fixture.componentRef.setInput('returnId', 123);
|
||||||
|
fixture.componentRef.setInput('receiptId', 456);
|
||||||
|
|
||||||
|
// Mock resource with data
|
||||||
|
(component.returnResource as any).value = signal(mockReceipt);
|
||||||
|
(component.returnResource as any).isLoading = signal(false);
|
||||||
|
|
||||||
|
expect(component.returnResource.value()).toEqual(mockReceipt);
|
||||||
|
expect(component.returnResource.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion';
|
||||||
|
import { ButtonComponent, IconButtonComponent } from '@isa/ui/buttons';
|
||||||
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import { isaActionChevronLeft, isaLoading } from '@isa/icons';
|
||||||
|
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
|
||||||
|
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
|
||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { createRemissionReturnReceiptResource } from './resources/remission-return-receipt.resource';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for displaying detailed information about a remission return receipt.
|
||||||
|
* Shows receipt header information and individual receipt items.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @selector remi-remission-return-receipt-details
|
||||||
|
* @standalone
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <remi-remission-return-receipt-details
|
||||||
|
* [returnId]="123"
|
||||||
|
* [receiptId]="456">
|
||||||
|
* </remi-remission-return-receipt-details>
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'remi-remission-return-receipt-details',
|
||||||
|
templateUrl: './remission-return-receipt-details.component.html',
|
||||||
|
styleUrls: ['./remission-return-receipt-details.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
ButtonComponent,
|
||||||
|
IconButtonComponent,
|
||||||
|
NgIcon,
|
||||||
|
RemissionReturnReceiptDetailsCardComponent,
|
||||||
|
RemissionReturnReceiptDetailsItemComponent,
|
||||||
|
],
|
||||||
|
providers: [provideIcons({ isaActionChevronLeft, isaLoading })],
|
||||||
|
})
|
||||||
|
export class RemissionReturnReceiptDetailsComponent {
|
||||||
|
/** Angular Location service for navigation */
|
||||||
|
location = inject(Location);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required input for the return ID.
|
||||||
|
* Automatically coerced to a number from string input.
|
||||||
|
* @input
|
||||||
|
* @required
|
||||||
|
*/
|
||||||
|
returnId = input.required<number, NumberInput>({
|
||||||
|
transform: coerceNumberProperty,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required input for the receipt ID.
|
||||||
|
* Automatically coerced to a number from string input.
|
||||||
|
* @input
|
||||||
|
* @required
|
||||||
|
*/
|
||||||
|
receiptId = input.required<number, NumberInput>({
|
||||||
|
transform: coerceNumberProperty,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource that fetches the return receipt data based on the provided IDs.
|
||||||
|
* Automatically updates when input IDs change.
|
||||||
|
*/
|
||||||
|
returnResource = createRemissionReturnReceiptResource(() => ({
|
||||||
|
returnId: this.returnId(),
|
||||||
|
receiptId: this.receiptId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal that extracts the receipt number from the resource.
|
||||||
|
* Returns a substring of the receipt number (characters 6-12) for display.
|
||||||
|
* @returns {string} The formatted receipt number or empty string if not available
|
||||||
|
*/
|
||||||
|
receiptNumber = computed(() => {
|
||||||
|
const ret = this.returnResource.value();
|
||||||
|
if (!ret) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.receiptNumber?.substring(6, 12) || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
canRemoveItems = computed(() => {
|
||||||
|
const ret = this.returnResource.value();
|
||||||
|
return !ret?.completed;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './product-group.resource';
|
||||||
|
export * from './remission-return-receipt.resource';
|
||||||
|
export * from './supplier.resource';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { inject, resource } from '@angular/core';
|
||||||
|
import { RemissionProductGroupService } from '@isa/remission/data-access';
|
||||||
|
|
||||||
|
export const productGroupResource = () => {
|
||||||
|
const remissionProductGroupService = inject(RemissionProductGroupService);
|
||||||
|
return resource({
|
||||||
|
loader: ({ abortSignal }) =>
|
||||||
|
remissionProductGroupService.fetchProductGroups(abortSignal),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { runInInjectionContext, Injector } from '@angular/core';
|
||||||
|
import { MockProvider } from 'ng-mocks';
|
||||||
|
import { createRemissionReturnReceiptResource } from './remission-return-receipt.resource';
|
||||||
|
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
|
||||||
|
import { Receipt } from '@isa/remission/data-access';
|
||||||
|
|
||||||
|
describe('createRemissionReturnReceiptResource', () => {
|
||||||
|
let mockService: any;
|
||||||
|
let mockReceipt: Receipt;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReceipt = {
|
||||||
|
id: 123,
|
||||||
|
receiptNumber: 'RR-2024-001234-ABC',
|
||||||
|
completed: true,
|
||||||
|
created: new Date('2024-01-15T10:30:00Z'),
|
||||||
|
supplier: {
|
||||||
|
id: 456,
|
||||||
|
name: 'Test Supplier',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
quantity: 5,
|
||||||
|
product: { id: 1, name: 'Product 1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
packageNumber: 'PKG-001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Receipt;
|
||||||
|
|
||||||
|
mockService = {
|
||||||
|
fetchRemissionReturnReceipt: vi.fn().mockResolvedValue(mockReceipt),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
MockProvider(RemissionReturnReceiptService, mockService),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource Creation', () => {
|
||||||
|
it('should create resource successfully', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createRemissionReturnReceiptResource(() => ({
|
||||||
|
receiptId: 123,
|
||||||
|
returnId: 456,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(resource.value).toBeDefined();
|
||||||
|
expect(resource.isLoading).toBeDefined();
|
||||||
|
expect(resource.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inject RemissionReturnReceiptService', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createRemissionReturnReceiptResource(() => ({
|
||||||
|
receiptId: 123,
|
||||||
|
returnId: 456,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(mockService).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource Parameters', () => {
|
||||||
|
it('should handle numeric parameters', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createRemissionReturnReceiptResource(() => ({
|
||||||
|
receiptId: 123,
|
||||||
|
returnId: 456,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string parameters', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createRemissionReturnReceiptResource(() => ({
|
||||||
|
receiptId: '123',
|
||||||
|
returnId: '456',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed parameter types', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createRemissionReturnReceiptResource(() => ({
|
||||||
|
receiptId: 123,
|
||||||
|
returnId: '456',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource State Management', () => {
|
||||||
|
it('should provide loading state', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createRemissionReturnReceiptResource(() => ({
|
||||||
|
receiptId: 123,
|
||||||
|
returnId: 456,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource.isLoading).toBeDefined();
|
||||||
|
expect(typeof resource.isLoading).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide error state', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createRemissionReturnReceiptResource(() => ({
|
||||||
|
receiptId: 123,
|
||||||
|
returnId: 456,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource.error).toBeDefined();
|
||||||
|
expect(typeof resource.error).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide value state', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createRemissionReturnReceiptResource(() => ({
|
||||||
|
receiptId: 123,
|
||||||
|
returnId: 456,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource.value).toBeDefined();
|
||||||
|
expect(typeof resource.value).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource Function', () => {
|
||||||
|
it('should create resource function correctly', () => {
|
||||||
|
const createResourceFn = () => createRemissionReturnReceiptResource(() => ({
|
||||||
|
receiptId: 123,
|
||||||
|
returnId: 456,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), createResourceFn);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(typeof resource.value).toBe('function');
|
||||||
|
expect(typeof resource.isLoading).toBe('function');
|
||||||
|
expect(typeof resource.error).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle resource initialization', () => {
|
||||||
|
const params = { receiptId: 123, returnId: 456 };
|
||||||
|
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createRemissionReturnReceiptResource(() => params)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(resource.value).toBeDefined();
|
||||||
|
expect(resource.isLoading).toBeDefined();
|
||||||
|
expect(resource.error).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { resource, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
RemissionReturnReceiptService,
|
||||||
|
FetchRemissionReturnParams,
|
||||||
|
} from '@isa/remission/data-access';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Angular resource for fetching a specific remission return receipt.
|
||||||
|
* The resource automatically manages loading state and caching.
|
||||||
|
*
|
||||||
|
* @function createRemissionReturnReceiptResource
|
||||||
|
* @param {Function} params - Function that returns the receipt and return IDs
|
||||||
|
* @param {string | number} params.receiptId - ID of the receipt to fetch
|
||||||
|
* @param {string | number} params.returnId - ID of the return containing the receipt
|
||||||
|
* @returns {Resource} Angular resource that manages the receipt data
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const receiptResource = createRemissionReturnReceiptResource(() => ({
|
||||||
|
* receiptId: '123',
|
||||||
|
* returnId: '456'
|
||||||
|
* }));
|
||||||
|
*
|
||||||
|
* // Access the resource value
|
||||||
|
* const receipt = receiptResource.value();
|
||||||
|
* const isLoading = receiptResource.isLoading();
|
||||||
|
*/
|
||||||
|
export const createRemissionReturnReceiptResource = (
|
||||||
|
params: () => FetchRemissionReturnParams,
|
||||||
|
) => {
|
||||||
|
const remissionReturnReceiptService = inject(RemissionReturnReceiptService);
|
||||||
|
return resource({
|
||||||
|
params,
|
||||||
|
loader: ({ abortSignal, params }) =>
|
||||||
|
remissionReturnReceiptService.fetchRemissionReturnReceipt(
|
||||||
|
params,
|
||||||
|
abortSignal,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { runInInjectionContext, Injector } from '@angular/core';
|
||||||
|
import { MockProvider } from 'ng-mocks';
|
||||||
|
import { createSupplierResource } from './supplier.resource';
|
||||||
|
import { RemissionSupplierService, Supplier } from '@isa/remission/data-access';
|
||||||
|
|
||||||
|
describe('createSupplierResource', () => {
|
||||||
|
let mockService: any;
|
||||||
|
let mockSuppliers: Supplier[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSuppliers = [
|
||||||
|
{
|
||||||
|
id: 123,
|
||||||
|
name: 'Test Supplier GmbH',
|
||||||
|
address: 'Test Street 1',
|
||||||
|
contactPerson: 'John Doe',
|
||||||
|
email: 'john@testsupplier.com',
|
||||||
|
phone: '+49123456789',
|
||||||
|
active: true,
|
||||||
|
} as Supplier,
|
||||||
|
{
|
||||||
|
id: 456,
|
||||||
|
name: 'Another Supplier Ltd',
|
||||||
|
address: 'Another Street 2',
|
||||||
|
contactPerson: 'Jane Smith',
|
||||||
|
email: 'jane@anothersupplier.com',
|
||||||
|
phone: '+49987654321',
|
||||||
|
active: true,
|
||||||
|
} as Supplier,
|
||||||
|
{
|
||||||
|
id: 789,
|
||||||
|
name: 'Inactive Supplier Corp',
|
||||||
|
address: 'Inactive Street 3',
|
||||||
|
contactPerson: 'Bob Wilson',
|
||||||
|
email: 'bob@inactivesupplier.com',
|
||||||
|
phone: '+49555666777',
|
||||||
|
active: false,
|
||||||
|
} as Supplier,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockService = {
|
||||||
|
fetchSuppliers: vi.fn().mockResolvedValue(mockSuppliers),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
MockProvider(RemissionSupplierService, mockService),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource Creation', () => {
|
||||||
|
it('should create resource successfully', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(resource.value).toBeDefined();
|
||||||
|
expect(resource.isLoading).toBeDefined();
|
||||||
|
expect(resource.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inject RemissionSupplierService', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(mockService).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource State Management', () => {
|
||||||
|
it('should provide loading state', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource.isLoading).toBeDefined();
|
||||||
|
expect(typeof resource.isLoading).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide error state', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource.error).toBeDefined();
|
||||||
|
expect(typeof resource.error).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide value state', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource.value).toBeDefined();
|
||||||
|
expect(typeof resource.value).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource Loader', () => {
|
||||||
|
it('should call service fetchSuppliers method', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(mockService.fetchSuppliers).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle successful service response', () => {
|
||||||
|
mockService.fetchSuppliers.mockResolvedValue(mockSuppliers);
|
||||||
|
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(resource.value).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle service returning empty array', () => {
|
||||||
|
mockService.fetchSuppliers.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(resource.value).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle service errors', () => {
|
||||||
|
const error = new Error('Service error');
|
||||||
|
mockService.fetchSuppliers.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(resource.error).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AbortSignal handling', () => {
|
||||||
|
it('should pass abortSignal to service', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(mockService.fetchSuppliers).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle aborted requests', () => {
|
||||||
|
const abortError = new Error('Request aborted');
|
||||||
|
mockService.fetchSuppliers.mockRejectedValue(abortError);
|
||||||
|
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(resource.error).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource without parameters', () => {
|
||||||
|
it('should create resource without requiring parameters', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(resource.value).toBeDefined();
|
||||||
|
expect(resource.isLoading).toBeDefined();
|
||||||
|
expect(resource.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should automatically load suppliers on creation', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(mockService.fetchSuppliers).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource Function', () => {
|
||||||
|
it('should create resource function correctly', () => {
|
||||||
|
const createResourceFn = () => createSupplierResource();
|
||||||
|
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), createResourceFn);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(typeof resource.value).toBe('function');
|
||||||
|
expect(typeof resource.isLoading).toBe('function');
|
||||||
|
expect(typeof resource.error).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple resource instances', () => {
|
||||||
|
const resource1 = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
const resource2 = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource1).toBeDefined();
|
||||||
|
expect(resource2).toBeDefined();
|
||||||
|
expect(resource1).not.toBe(resource2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Service Integration', () => {
|
||||||
|
it('should integrate correctly with RemissionSupplierService', () => {
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(mockService.fetchSuppliers).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different supplier data types', () => {
|
||||||
|
const differentSuppliers = [
|
||||||
|
{ id: 1, name: 'Supplier One', active: true },
|
||||||
|
{ id: 2, name: 'Supplier Two', active: false },
|
||||||
|
] as Supplier[];
|
||||||
|
|
||||||
|
mockService.fetchSuppliers.mockResolvedValue(differentSuppliers);
|
||||||
|
|
||||||
|
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
|
||||||
|
createSupplierResource()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resource).toBeDefined();
|
||||||
|
expect(resource.value).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { resource, inject } from '@angular/core';
|
||||||
|
import { RemissionSupplierService } from '@isa/remission/data-access';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Angular resource for fetching supplier data.
|
||||||
|
* The resource automatically loads all suppliers for the assigned stock.
|
||||||
|
* Results are cached for performance.
|
||||||
|
*
|
||||||
|
* @function createSupplierResource
|
||||||
|
* @returns {Resource} Angular resource that manages supplier data
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const supplierResource = createSupplierResource();
|
||||||
|
*
|
||||||
|
* // Access the suppliers
|
||||||
|
* const suppliers = supplierResource.value();
|
||||||
|
* const isLoading = supplierResource.isLoading();
|
||||||
|
*
|
||||||
|
* // Find a specific supplier
|
||||||
|
* const supplier = suppliers?.find(s => s.id === supplierId);
|
||||||
|
*/
|
||||||
|
export const createSupplierResource = () => {
|
||||||
|
const remissionSupplierService = inject(RemissionSupplierService);
|
||||||
|
return resource({
|
||||||
|
loader: ({ abortSignal }) =>
|
||||||
|
remissionSupplierService.fetchSuppliers(abortSignal),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import '@analogjs/vitest-angular/setup-zone';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting,
|
||||||
|
} from '@angular/platform-browser/testing';
|
||||||
|
import { getTestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
getTestBed().initTestEnvironment(
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting(),
|
||||||
|
);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"importHelpers": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"typeCheckHostBindings": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.lib.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../../dist/out-tsc",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/test-setup.ts",
|
||||||
|
"jest.config.ts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx"
|
||||||
|
],
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../../dist/out-tsc",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals",
|
||||||
|
"vitest/importMeta",
|
||||||
|
"vite/client",
|
||||||
|
"node",
|
||||||
|
"vitest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
],
|
||||||
|
"files": ["src/test-setup.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// <reference types='vitest' />
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import angular from '@analogjs/vite-plugin-angular';
|
||||||
|
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||||
|
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
root: __dirname,
|
||||||
|
cacheDir:
|
||||||
|
'../../../../node_modules/.vite/libs/remission/feature/remission-return-receipt-details',
|
||||||
|
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
|
// Uncomment this if you are using workers.
|
||||||
|
// worker: {
|
||||||
|
// plugins: [ nxViteTsPaths() ],
|
||||||
|
// },
|
||||||
|
test: {
|
||||||
|
watch: false,
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
setupFiles: ['src/test-setup.ts'],
|
||||||
|
reporters: ['default'],
|
||||||
|
coverage: {
|
||||||
|
reportsDirectory:
|
||||||
|
'../../../../coverage/libs/remission/feature/remission-return-receipt-details',
|
||||||
|
provider: 'v8' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# remission-feature-remission-return-receipt-list
|
||||||
|
|
||||||
|
This library was generated with [Nx](https://nx.dev).
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `nx test remission-feature-remission-return-receipt-list` to execute the unit tests.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const nx = require('@nx/eslint-plugin');
|
||||||
|
const baseConfig = require('../../../../eslint.config.js');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
...baseConfig,
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'remi',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'remi',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "remission-feature-remission-return-receipt-list",
|
||||||
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "libs/remission/feature/remission-return-receipt-list/src",
|
||||||
|
"prefix": "remi",
|
||||||
|
"projectType": "library",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {
|
||||||
|
"test": {
|
||||||
|
"executor": "@nx/vite:test",
|
||||||
|
"outputs": ["{options.reportsDirectory}"],
|
||||||
|
"options": {
|
||||||
|
"reportsDirectory": "../../../../coverage/libs/remission/feature/remission-return-receipt-list"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './lib/routes';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<div class="flex flex-rows justify-end">
|
||||||
|
<filter-order-by-toolbar class="w-[44.375rem]"></filter-order-by-toolbar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-flow-rows grid-cols-1 gap-4">
|
||||||
|
@for (remissionReturn of returns(); track remissionReturn[1].id) {
|
||||||
|
<a [routerLink]="[remissionReturn[0].id, remissionReturn[1].id]">
|
||||||
|
<remi-return-receipt-list-item
|
||||||
|
[remissionReturn]="remissionReturn[0]"
|
||||||
|
></remi-return-receipt-list-item>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
@apply grid grid-flow-row gap-8 p-6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { MockComponent, MockProvider } from 'ng-mocks';
|
||||||
|
import { RemissionReturnReceiptListComponent } from './remission-return-receipt-list.component';
|
||||||
|
import { ReturnReceiptListItemComponent } from './return-receipt-list-item/return-receipt-list-item.component';
|
||||||
|
import {
|
||||||
|
RemissionReturnReceiptService,
|
||||||
|
Return,
|
||||||
|
} from '@isa/remission/data-access';
|
||||||
|
import { OrderByToolbarComponent, FilterService } from '@isa/shared/filter';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
|
||||||
|
// Mock the filter providers
|
||||||
|
vi.mock('@isa/shared/filter', async () => {
|
||||||
|
const actual = await vi.importActual('@isa/shared/filter');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
provideFilter: vi.fn(() => []),
|
||||||
|
withQueryParamsSync: vi.fn(() => ({})),
|
||||||
|
withQuerySettings: vi.fn(() => ({})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemissionReturnReceiptListComponent', () => {
|
||||||
|
let component: RemissionReturnReceiptListComponent;
|
||||||
|
let fixture: ComponentFixture<RemissionReturnReceiptListComponent>;
|
||||||
|
let mockRemissionReturnReceiptService: {
|
||||||
|
fetchCompletedRemissionReturnReceipts: ReturnType<typeof vi.fn>;
|
||||||
|
fetchIncompletedRemissionReturnReceipts: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockFilterService: {
|
||||||
|
orderBy: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCompletedReturns: Return[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
data: {
|
||||||
|
id: 101,
|
||||||
|
receiptNumber: 'REC-2024-001',
|
||||||
|
created: '2024-01-15T09:00:00.000Z',
|
||||||
|
completed: '2024-01-15T10:30:00.000Z',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Return,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
data: {
|
||||||
|
id: 102,
|
||||||
|
receiptNumber: 'REC-2024-002',
|
||||||
|
created: '2024-01-16T13:00:00.000Z',
|
||||||
|
completed: '2024-01-16T14:45:00.000Z',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Return,
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockIncompletedReturns: Return[] = [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 103,
|
||||||
|
data: {
|
||||||
|
id: 103,
|
||||||
|
receiptNumber: 'REC-2024-003',
|
||||||
|
created: '2024-01-17T08:00:00.000Z',
|
||||||
|
completed: undefined,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Return,
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockRemissionReturnReceiptService = {
|
||||||
|
fetchCompletedRemissionReturnReceipts: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(mockCompletedReturns),
|
||||||
|
fetchIncompletedRemissionReturnReceipts: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(mockIncompletedReturns),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFilterService = {
|
||||||
|
orderBy: signal([{ selected: false, by: 'created', dir: 'asc' }]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [RemissionReturnReceiptListComponent],
|
||||||
|
providers: [
|
||||||
|
MockProvider(
|
||||||
|
RemissionReturnReceiptService,
|
||||||
|
mockRemissionReturnReceiptService,
|
||||||
|
),
|
||||||
|
MockProvider(FilterService, mockFilterService),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideComponent(RemissionReturnReceiptListComponent, {
|
||||||
|
remove: {
|
||||||
|
imports: [ReturnReceiptListItemComponent, OrderByToolbarComponent],
|
||||||
|
},
|
||||||
|
add: {
|
||||||
|
imports: [
|
||||||
|
MockComponent(ReturnReceiptListItemComponent),
|
||||||
|
MockComponent(OrderByToolbarComponent),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(RemissionReturnReceiptListComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component Setup', () => {
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct configuration', () => {
|
||||||
|
expect(component).toBeDefined();
|
||||||
|
expect(fixture.componentInstance).toBe(component);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resources Loading', () => {
|
||||||
|
it('should initialize resources on creation', () => {
|
||||||
|
// Resources are created in the component constructor
|
||||||
|
expect(component.completedRemissionReturnsResource).toBeDefined();
|
||||||
|
expect(component.incompletedRemissionReturnsResource).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call service methods when resources load', async () => {
|
||||||
|
// Create a new component instance to test fresh loading
|
||||||
|
const newFixture = TestBed.createComponent(
|
||||||
|
RemissionReturnReceiptListComponent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear previous calls
|
||||||
|
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts.mockClear();
|
||||||
|
mockRemissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts.mockClear();
|
||||||
|
|
||||||
|
// Initialize the component to trigger resource loading
|
||||||
|
newFixture.detectChanges();
|
||||||
|
await newFixture.whenStable();
|
||||||
|
|
||||||
|
// Verify that both service methods were called when resources load
|
||||||
|
expect(
|
||||||
|
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts,
|
||||||
|
).toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
mockRemissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts,
|
||||||
|
).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle loading state', () => {
|
||||||
|
// Check loading state
|
||||||
|
expect(
|
||||||
|
component.completedRemissionReturnsResource.isLoading(),
|
||||||
|
).toBeDefined();
|
||||||
|
expect(
|
||||||
|
component.incompletedRemissionReturnsResource.isLoading(),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error state when service fails', async () => {
|
||||||
|
// Mock service to throw errors
|
||||||
|
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts.mockRejectedValue(
|
||||||
|
new Error('Completed returns service failed')
|
||||||
|
);
|
||||||
|
mockRemissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts.mockRejectedValue(
|
||||||
|
new Error('Incomplete returns service failed')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a new component to test error handling
|
||||||
|
const errorFixture = TestBed.createComponent(RemissionReturnReceiptListComponent);
|
||||||
|
const errorComponent = errorFixture.componentInstance;
|
||||||
|
|
||||||
|
// Trigger change detection to initiate resource loading
|
||||||
|
errorFixture.detectChanges();
|
||||||
|
await errorFixture.whenStable();
|
||||||
|
|
||||||
|
// Check that resources have error signals available
|
||||||
|
expect(errorComponent.completedRemissionReturnsResource.error).toBeDefined();
|
||||||
|
expect(errorComponent.incompletedRemissionReturnsResource.error).toBeDefined();
|
||||||
|
|
||||||
|
// Check that status signals indicate error states
|
||||||
|
expect(errorComponent.completedRemissionReturnsResource.status).toBeDefined();
|
||||||
|
expect(errorComponent.incompletedRemissionReturnsResource.status).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('returns computed signal', () => {
|
||||||
|
it('should combine completed and incompleted returns with incompleted first', async () => {
|
||||||
|
// Mock the resource values
|
||||||
|
(component.completedRemissionReturnsResource as any).value =
|
||||||
|
signal(mockCompletedReturns);
|
||||||
|
(component.incompletedRemissionReturnsResource as any).value = signal(
|
||||||
|
mockIncompletedReturns,
|
||||||
|
);
|
||||||
|
|
||||||
|
const returns = component.returns();
|
||||||
|
|
||||||
|
expect(returns).toHaveLength(3);
|
||||||
|
// Returns should be tuples [Return, Receipt]
|
||||||
|
expect(returns[0][0]).toBe(mockIncompletedReturns[0]); // Incompleted first
|
||||||
|
expect(returns[0][1]).toBe(mockIncompletedReturns[0].receipts[0].data);
|
||||||
|
expect(returns[1][0]).toBe(mockCompletedReturns[0]);
|
||||||
|
expect(returns[1][1]).toBe(mockCompletedReturns[0].receipts[0].data);
|
||||||
|
expect(returns[2][0]).toBe(mockCompletedReturns[1]);
|
||||||
|
expect(returns[2][1]).toBe(mockCompletedReturns[1].receipts[0].data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty completed returns', () => {
|
||||||
|
(component.completedRemissionReturnsResource as any).value = signal([]);
|
||||||
|
(component.incompletedRemissionReturnsResource as any).value = signal(
|
||||||
|
mockIncompletedReturns,
|
||||||
|
);
|
||||||
|
|
||||||
|
const returns = component.returns();
|
||||||
|
|
||||||
|
expect(returns).toHaveLength(1);
|
||||||
|
expect(returns[0][0]).toBe(mockIncompletedReturns[0]);
|
||||||
|
expect(returns[0][1]).toBe(mockIncompletedReturns[0].receipts[0].data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty incompleted returns', () => {
|
||||||
|
(component.completedRemissionReturnsResource as any).value =
|
||||||
|
signal(mockCompletedReturns);
|
||||||
|
(component.incompletedRemissionReturnsResource as any).value = signal([]);
|
||||||
|
|
||||||
|
const returns = component.returns();
|
||||||
|
|
||||||
|
expect(returns).toHaveLength(2);
|
||||||
|
expect(returns[0][0]).toBe(mockCompletedReturns[0]);
|
||||||
|
expect(returns[0][1]).toBe(mockCompletedReturns[0].receipts[0].data);
|
||||||
|
expect(returns[1][0]).toBe(mockCompletedReturns[1]);
|
||||||
|
expect(returns[1][1]).toBe(mockCompletedReturns[1].receipts[0].data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle both empty returns', () => {
|
||||||
|
(component.completedRemissionReturnsResource as any).value = signal([]);
|
||||||
|
(component.incompletedRemissionReturnsResource as any).value = signal([]);
|
||||||
|
|
||||||
|
const returns = component.returns();
|
||||||
|
|
||||||
|
expect(returns).toHaveLength(0);
|
||||||
|
expect(returns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null values from resources', () => {
|
||||||
|
(component.completedRemissionReturnsResource as any).value = signal(null);
|
||||||
|
(component.incompletedRemissionReturnsResource as any).value =
|
||||||
|
signal(null);
|
||||||
|
|
||||||
|
const returns = component.returns();
|
||||||
|
|
||||||
|
expect(returns).toHaveLength(0);
|
||||||
|
expect(returns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined values from resources', () => {
|
||||||
|
(component.completedRemissionReturnsResource as any).value =
|
||||||
|
signal(undefined);
|
||||||
|
(component.incompletedRemissionReturnsResource as any).value =
|
||||||
|
signal(undefined);
|
||||||
|
|
||||||
|
const returns = component.returns();
|
||||||
|
|
||||||
|
expect(returns).toHaveLength(0);
|
||||||
|
expect(returns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed null and valid values', () => {
|
||||||
|
(component.completedRemissionReturnsResource as any).value =
|
||||||
|
signal(mockCompletedReturns);
|
||||||
|
(component.incompletedRemissionReturnsResource as any).value =
|
||||||
|
signal(null);
|
||||||
|
|
||||||
|
const returns = component.returns();
|
||||||
|
|
||||||
|
expect(returns).toHaveLength(2);
|
||||||
|
expect(returns[0][0]).toBe(mockCompletedReturns[0]);
|
||||||
|
expect(returns[0][1]).toBe(mockCompletedReturns[0].receipts[0].data);
|
||||||
|
expect(returns[1][0]).toBe(mockCompletedReturns[1]);
|
||||||
|
expect(returns[1][1]).toBe(mockCompletedReturns[1].receipts[0].data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Query Settings', () => {
|
||||||
|
it('should have correct filter configuration', () => {
|
||||||
|
// This is tested indirectly through the component setup
|
||||||
|
// The actual filter behavior would be tested in integration tests
|
||||||
|
expect(component).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should define order by options', () => {
|
||||||
|
// The QUERY_SETTINGS constant is private, but we can verify
|
||||||
|
// that the component is configured with filter providers
|
||||||
|
expect(component).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component Lifecycle', () => {
|
||||||
|
it('should handle component destruction', () => {
|
||||||
|
fixture.destroy();
|
||||||
|
expect(component).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update when input changes', async () => {
|
||||||
|
// Simulate resource updates
|
||||||
|
const newCompletedReturns = [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 104,
|
||||||
|
data: {
|
||||||
|
id: 104,
|
||||||
|
receiptNumber: 'REC-2024-004',
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Return,
|
||||||
|
];
|
||||||
|
|
||||||
|
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts.mockResolvedValue(
|
||||||
|
newCompletedReturns,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock resource value update
|
||||||
|
(component.completedRemissionReturnsResource as any).value =
|
||||||
|
signal(newCompletedReturns);
|
||||||
|
(component.incompletedRemissionReturnsResource as any).value = signal(
|
||||||
|
mockIncompletedReturns,
|
||||||
|
);
|
||||||
|
|
||||||
|
const returns = component.returns();
|
||||||
|
|
||||||
|
// Check that the tuple contains the new return
|
||||||
|
expect(
|
||||||
|
returns.some(
|
||||||
|
([returnData, _]) => returnData === newCompletedReturns[0],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle service errors gracefully', async () => {
|
||||||
|
// Mock one service to succeed and one to fail
|
||||||
|
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts.mockRejectedValue(
|
||||||
|
new Error('Network error')
|
||||||
|
);
|
||||||
|
mockRemissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue(
|
||||||
|
mockIncompletedReturns
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a new component to test graceful error handling
|
||||||
|
const errorFixture = TestBed.createComponent(RemissionReturnReceiptListComponent);
|
||||||
|
const errorComponent = errorFixture.componentInstance;
|
||||||
|
|
||||||
|
// Trigger resource loading
|
||||||
|
errorFixture.detectChanges();
|
||||||
|
await errorFixture.whenStable();
|
||||||
|
|
||||||
|
// Verify that the component handles errors gracefully
|
||||||
|
// The component should still function with partial data
|
||||||
|
expect(errorComponent).toBeTruthy();
|
||||||
|
expect(errorComponent.completedRemissionReturnsResource).toBeDefined();
|
||||||
|
expect(errorComponent.incompletedRemissionReturnsResource).toBeDefined();
|
||||||
|
|
||||||
|
// Mock successful resource values for the returns computed signal test
|
||||||
|
(errorComponent.completedRemissionReturnsResource as any).value = signal(null);
|
||||||
|
(errorComponent.incompletedRemissionReturnsResource as any).value = signal(mockIncompletedReturns);
|
||||||
|
|
||||||
|
// The returns computed signal should handle null/error states gracefully
|
||||||
|
const returns = errorComponent.returns();
|
||||||
|
expect(returns).toHaveLength(1);
|
||||||
|
expect(returns[0][0]).toBe(mockIncompletedReturns[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial failures', async () => {
|
||||||
|
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts.mockRejectedValue(
|
||||||
|
new Error('Failed'),
|
||||||
|
);
|
||||||
|
mockRemissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue(
|
||||||
|
mockIncompletedReturns,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newFixture = TestBed.createComponent(
|
||||||
|
RemissionReturnReceiptListComponent,
|
||||||
|
);
|
||||||
|
const newComponent = newFixture.componentInstance;
|
||||||
|
|
||||||
|
await newFixture.whenStable();
|
||||||
|
|
||||||
|
// Mock the resource values for testing
|
||||||
|
(newComponent.completedRemissionReturnsResource as any).value =
|
||||||
|
signal(null);
|
||||||
|
(newComponent.incompletedRemissionReturnsResource as any).value = signal(
|
||||||
|
mockIncompletedReturns,
|
||||||
|
);
|
||||||
|
|
||||||
|
const returns = newComponent.returns();
|
||||||
|
|
||||||
|
expect(returns).toHaveLength(1);
|
||||||
|
expect(returns[0][0]).toBe(mockIncompletedReturns[0]);
|
||||||
|
expect(returns[0][1]).toBe(mockIncompletedReturns[0].receipts[0].data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle very large return lists', () => {
|
||||||
|
const largeCompletedReturns = Array.from(
|
||||||
|
{ length: 1000 },
|
||||||
|
(_, i) =>
|
||||||
|
({
|
||||||
|
id: i,
|
||||||
|
receipts: [],
|
||||||
|
}) as Return,
|
||||||
|
);
|
||||||
|
|
||||||
|
const largeIncompletedReturns = Array.from(
|
||||||
|
{ length: 500 },
|
||||||
|
(_, i) =>
|
||||||
|
({
|
||||||
|
id: i + 1000,
|
||||||
|
receipts: [],
|
||||||
|
}) as Return,
|
||||||
|
);
|
||||||
|
|
||||||
|
(component.completedRemissionReturnsResource as any).value = signal(
|
||||||
|
largeCompletedReturns,
|
||||||
|
);
|
||||||
|
(component.incompletedRemissionReturnsResource as any).value = signal(
|
||||||
|
largeIncompletedReturns,
|
||||||
|
);
|
||||||
|
|
||||||
|
const returns = component.returns();
|
||||||
|
|
||||||
|
// With no receipts, the flattened result should be empty
|
||||||
|
expect(returns).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain order when resources update', () => {
|
||||||
|
// Test that the order logic correctly maintains incompleted first, then completed
|
||||||
|
const newCompletedReturns: Return[] = [
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 105,
|
||||||
|
data: {
|
||||||
|
id: 105,
|
||||||
|
receiptNumber: 'REC-2024-005',
|
||||||
|
created: '2024-01-18T10:00:00.000Z',
|
||||||
|
completed: '2024-01-18T11:00:00.000Z',
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Return,
|
||||||
|
];
|
||||||
|
|
||||||
|
const newIncompletedReturns: Return[] = [
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 106,
|
||||||
|
data: {
|
||||||
|
id: 106,
|
||||||
|
receiptNumber: 'REC-2024-006',
|
||||||
|
created: '2024-01-19T08:00:00.000Z',
|
||||||
|
completed: undefined,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Return,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Simulate resource updates by mocking the resource values
|
||||||
|
(component.completedRemissionReturnsResource as any).value = signal(newCompletedReturns);
|
||||||
|
(component.incompletedRemissionReturnsResource as any).value = signal(newIncompletedReturns);
|
||||||
|
|
||||||
|
const returns = component.returns();
|
||||||
|
|
||||||
|
expect(returns).toHaveLength(2);
|
||||||
|
|
||||||
|
// Verify that incompleted returns come first
|
||||||
|
expect(returns[0][0]).toBe(newIncompletedReturns[0]);
|
||||||
|
expect(returns[0][1]).toBe(newIncompletedReturns[0].receipts[0].data);
|
||||||
|
|
||||||
|
// Then completed returns
|
||||||
|
expect(returns[1][0]).toBe(newCompletedReturns[0]);
|
||||||
|
expect(returns[1][1]).toBe(newCompletedReturns[0].receipts[0].data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
resource,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ReturnReceiptListItemComponent } from './return-receipt-list-item/return-receipt-list-item.component';
|
||||||
|
import {
|
||||||
|
Receipt,
|
||||||
|
RemissionReturnReceiptService,
|
||||||
|
Return,
|
||||||
|
} from '@isa/remission/data-access';
|
||||||
|
import {
|
||||||
|
provideFilter,
|
||||||
|
withQueryParamsSync,
|
||||||
|
withQuerySettings,
|
||||||
|
OrderByToolbarComponent,
|
||||||
|
FilterService,
|
||||||
|
} from '@isa/shared/filter';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { compareAsc, compareDesc } from 'date-fns';
|
||||||
|
import { RETURN_RECEIPT_QUERY_SETTINGS } from './remission-return-receipt-list.query-settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays a list of remission return receipts.
|
||||||
|
* Fetches both completed and incomplete receipts and combines them for display.
|
||||||
|
* Supports filtering and sorting through query parameters.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @selector remi-remission-return-receipt-list
|
||||||
|
* @standalone
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <remi-remission-return-receipt-list></remi-remission-return-receipt-list>
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'remi-remission-return-receipt-list',
|
||||||
|
templateUrl: './remission-return-receipt-list.component.html',
|
||||||
|
styleUrls: ['./remission-return-receipt-list.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
ReturnReceiptListItemComponent,
|
||||||
|
OrderByToolbarComponent,
|
||||||
|
RouterLink,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
provideFilter(
|
||||||
|
withQuerySettings(RETURN_RECEIPT_QUERY_SETTINGS),
|
||||||
|
withQueryParamsSync(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class RemissionReturnReceiptListComponent {
|
||||||
|
/** Private instance of the remission return receipt service */
|
||||||
|
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
|
||||||
|
|
||||||
|
#filter = inject(FilterService);
|
||||||
|
|
||||||
|
orderDateBy = computed(() => this.#filter.orderBy().find((o) => o.selected));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource that fetches completed remission return receipts.
|
||||||
|
* Automatically loads when the component is initialized.
|
||||||
|
*/
|
||||||
|
completedRemissionReturnsResource = resource({
|
||||||
|
loader: () =>
|
||||||
|
this.#remissionReturnReceiptService.fetchCompletedRemissionReturnReceipts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource that fetches incomplete remission return receipts.
|
||||||
|
* Automatically loads when the component is initialized.
|
||||||
|
*/
|
||||||
|
incompletedRemissionReturnsResource = resource({
|
||||||
|
loader: () =>
|
||||||
|
this.#remissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal that combines completed and incomplete returns.
|
||||||
|
* Maps each return with its receipts into tuples for display.
|
||||||
|
* When date ordering is selected, sorts by completion date with incomplete items first.
|
||||||
|
* @returns {Array<[Return, Receipt]>} Array of tuples containing return and receipt pairs
|
||||||
|
*/
|
||||||
|
returns = computed(() => {
|
||||||
|
const completed = this.completedRemissionReturnsResource.value() || [];
|
||||||
|
const incompleted = this.incompletedRemissionReturnsResource.value() || [];
|
||||||
|
const orderBy = this.orderDateBy();
|
||||||
|
|
||||||
|
const allReturnReceiptTuples = [...incompleted, ...completed].flatMap(
|
||||||
|
(ret) =>
|
||||||
|
ret.receipts
|
||||||
|
.filter((rec) => rec.data != null)
|
||||||
|
.map((rec) => [ret, rec.data] as [Return, Receipt]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!orderBy) {
|
||||||
|
return allReturnReceiptTuples;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderByField = orderBy.by as 'created' | 'completed';
|
||||||
|
const compareFn = orderBy.dir === 'desc' ? compareDesc : compareAsc;
|
||||||
|
|
||||||
|
return allReturnReceiptTuples.sort((a, b) => {
|
||||||
|
const dateA = a[1][orderByField];
|
||||||
|
const dateB = b[1][orderByField];
|
||||||
|
|
||||||
|
if (!dateA) return -1;
|
||||||
|
if (!dateB) return 1;
|
||||||
|
|
||||||
|
return compareFn(dateA, dateB);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { QuerySettings } from '@isa/shared/filter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query settings configuration for filtering and sorting return receipts.
|
||||||
|
* Provides options to sort by date in ascending or descending order.
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
|
export const RETURN_RECEIPT_QUERY_SETTINGS: QuerySettings = {
|
||||||
|
filter: [],
|
||||||
|
input: [],
|
||||||
|
orderBy: [
|
||||||
|
// {
|
||||||
|
// by: 'completed',
|
||||||
|
// label: 'Remissiondatum',
|
||||||
|
// desc: true,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// by: 'completed',
|
||||||
|
// label: 'Remissiondatum',
|
||||||
|
// desc: false,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
by: 'created',
|
||||||
|
label: 'Datum',
|
||||||
|
desc: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
by: 'created',
|
||||||
|
label: 'Datum',
|
||||||
|
desc: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<div class="flex flex-col">
|
||||||
|
<div>Warenbegleitschein</div>
|
||||||
|
<div class="isa-text-body-1-bold">#{{ receiptNumber() }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div>Anzahl Positionen</div>
|
||||||
|
<div class="isa-text-body-1-bold">{{ itemQuantity() }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow"></div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div>Status</div>
|
||||||
|
<div class="isa-text-body-1-bold">{{ status() }}</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
@apply flex flex-row justify-start gap-6 p-6 bg-isa-neutral-400 rounded-2xl text-isa-neutral-900 isa-text-body-1-regular;
|
||||||
|
}
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { ReturnReceiptListItemComponent } from './return-receipt-list-item.component';
|
||||||
|
import { Return } from '@isa/remission/data-access';
|
||||||
|
|
||||||
|
describe('ReturnReceiptListItemComponent', () => {
|
||||||
|
let component: ReturnReceiptListItemComponent;
|
||||||
|
let fixture: ComponentFixture<ReturnReceiptListItemComponent>;
|
||||||
|
|
||||||
|
const createMockReturn = (overrides: Partial<Return> = {}): Return => ({
|
||||||
|
id: 1,
|
||||||
|
receipts: [],
|
||||||
|
...overrides,
|
||||||
|
} as Return);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ReturnReceiptListItemComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ReturnReceiptListItemComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component Setup', () => {
|
||||||
|
it('should create', () => {
|
||||||
|
fixture.componentRef.setInput('remissionReturn', createMockReturn());
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have remissionReturn as required input', () => {
|
||||||
|
fixture.componentRef.setInput('remissionReturn', createMockReturn());
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(component.remissionReturn()).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('receiptNumber computed signal', () => {
|
||||||
|
it('should return "Keine Belege vorhanden" when no receipts', () => {
|
||||||
|
const mockReturn = createMockReturn({ receipts: [] });
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('Keine Belege vorhanden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return single receipt number substring', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
receiptNumber: 'REC-2024-001-ABC',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('24-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return multiple receipt numbers joined with comma', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
receiptNumber: 'REC-2024-001-ABC',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
receiptNumber: 'REC-2024-002-DEF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
data: {
|
||||||
|
id: 3,
|
||||||
|
receiptNumber: 'REC-2024-003-GHI',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('24-001, 24-002, 24-003');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle receipts with null data', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: null as any,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
receiptNumber: 'REC-2024-002-DEF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('24-002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle receipts with undefined receiptNumber', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
receiptNumber: undefined as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
receiptNumber: 'REC-2024-002-DEF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('24-002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle short receipt numbers', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
receiptNumber: 'SHORT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('itemQuantity computed signal', () => {
|
||||||
|
it('should return 0 when no receipts', () => {
|
||||||
|
const mockReturn = createMockReturn({ receipts: [] });
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.itemQuantity()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return sum of all items across receipts', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
items: new Array(5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
items: new Array(3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
data: {
|
||||||
|
id: 3,
|
||||||
|
items: new Array(7),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.itemQuantity()).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle receipts with null data', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: null as any,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
items: new Array(3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.itemQuantity()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle receipts with undefined items', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
items: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
items: new Array(5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.itemQuantity()).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle receipts with empty items array', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
items: new Array(2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.itemQuantity()).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('completed computed signal', () => {
|
||||||
|
it('should return "Offen" when no receipts are completed', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.completed()).toBe('Offen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "Abgeschlossen" when at least one receipt is completed', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
data: {
|
||||||
|
id: 3,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.completed()).toBe('Abgeschlossen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "Abgeschlossen" when all receipts are completed', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.completed()).toBe('Abgeschlossen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "Offen" when no receipts exist', () => {
|
||||||
|
const mockReturn = createMockReturn({ receipts: [] });
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.completed()).toBe('Offen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle receipts with null data', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: null as any,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.completed()).toBe('Offen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle receipts with undefined completed status', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
completed: undefined as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.completed()).toBe('Abgeschlossen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component reactivity', () => {
|
||||||
|
it('should update computed signals when input changes', () => {
|
||||||
|
const initialReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
receiptNumber: 'REC-2024-001-ABC',
|
||||||
|
items: new Array(3),
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', initialReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('24-001');
|
||||||
|
expect(component.itemQuantity()).toBe(3);
|
||||||
|
expect(component.completed()).toBe('Offen');
|
||||||
|
|
||||||
|
const updatedReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
receiptNumber: 'REC-2024-002-DEF',
|
||||||
|
items: new Array(5),
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
receiptNumber: 'REC-2024-003-GHI',
|
||||||
|
items: new Array(2),
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', updatedReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('24-002, 24-003');
|
||||||
|
expect(component.itemQuantity()).toBe(7);
|
||||||
|
expect(component.completed()).toBe('Abgeschlossen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('status alias', () => {
|
||||||
|
it('should have status as an alias for completed', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.status()).toBe(component.completed());
|
||||||
|
expect(component.status()).toBe('Abgeschlossen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update status when completed changes', () => {
|
||||||
|
const initialReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', initialReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.status()).toBe('Offen');
|
||||||
|
|
||||||
|
const updatedReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', updatedReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.status()).toBe('Abgeschlossen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle return with deeply nested null values', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: null as any,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
receiptNumber: null as any,
|
||||||
|
items: null as any,
|
||||||
|
completed: null as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('');
|
||||||
|
expect(component.itemQuantity()).toBe(0);
|
||||||
|
expect(component.completed()).toBe('Offen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long receipt numbers', () => {
|
||||||
|
const mockReturn = createMockReturn({
|
||||||
|
receipts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
receiptNumber: 'PREFIX-VERY-LONG-RECEIPT-NUMBER-THAT-EXCEEDS-NORMAL-LENGTH',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.receiptNumber()).toBe('-VERY-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large number of receipts', () => {
|
||||||
|
const receipts = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
data: {
|
||||||
|
id: i + 1,
|
||||||
|
receiptNumber: `REC-2024-${String(i + 1).padStart(3, '0')}-ABC`,
|
||||||
|
items: new Array(2),
|
||||||
|
completed: i % 2 === 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockReturn = createMockReturn({ receipts });
|
||||||
|
fixture.componentRef.setInput('remissionReturn', mockReturn);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.itemQuantity()).toBe(200);
|
||||||
|
expect(component.completed()).toBe('Abgeschlossen');
|
||||||
|
expect(component.receiptNumber()).toContain('24-001');
|
||||||
|
expect(component.receiptNumber()).toContain('24-100');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Receipt, Return } from '@isa/remission/data-access';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays a single return receipt item in the list view.
|
||||||
|
* Shows receipt number, item quantity, and status information.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @selector remi-return-receipt-list-item
|
||||||
|
* @standalone
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <remi-return-receipt-list-item
|
||||||
|
* [remissionReturn]="returnData"
|
||||||
|
* [returnReceipt]="receiptData">
|
||||||
|
* </remi-return-receipt-list-item>
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'remi-return-receipt-list-item',
|
||||||
|
templateUrl: './return-receipt-list-item.component.html',
|
||||||
|
styleUrls: ['./return-receipt-list-item.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
})
|
||||||
|
export class ReturnReceiptListItemComponent {
|
||||||
|
/**
|
||||||
|
* Required input for the return data.
|
||||||
|
* @input
|
||||||
|
* @required
|
||||||
|
*/
|
||||||
|
remissionReturn = input.required<Return>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal that extracts and formats receipt numbers from all receipts.
|
||||||
|
* Returns "Keine Belege vorhanden" if no receipts, otherwise returns formatted receipt numbers.
|
||||||
|
* @returns {string} The formatted receipt numbers or message
|
||||||
|
*/
|
||||||
|
receiptNumber = computed(() => {
|
||||||
|
const returnData = this.remissionReturn();
|
||||||
|
|
||||||
|
if (!returnData.receipts || returnData.receipts.length === 0) {
|
||||||
|
return 'Keine Belege vorhanden';
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiptNumbers = returnData.receipts
|
||||||
|
.map((receipt) => receipt.data?.receiptNumber)
|
||||||
|
.filter((receiptNumber) => receiptNumber && receiptNumber.length >= 12)
|
||||||
|
.map((receiptNumber) => receiptNumber!.substring(6, 12));
|
||||||
|
|
||||||
|
return receiptNumbers.length > 0 ? receiptNumbers.join(', ') : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal that calculates the total quantity of all items across all receipts.
|
||||||
|
* @returns {number} Total quantity of items
|
||||||
|
*/
|
||||||
|
itemQuantity = computed(() => {
|
||||||
|
const returnData = this.remissionReturn();
|
||||||
|
|
||||||
|
if (!returnData.receipts || returnData.receipts.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnData.receipts.reduce((totalItems, receipt) => {
|
||||||
|
const items = receipt.data?.items;
|
||||||
|
return totalItems + (items ? items.length : 0);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed signal that determines the completion status.
|
||||||
|
* Returns "Abgeschlossen" if any receipt is completed, "Offen" otherwise.
|
||||||
|
* @returns {'Abgeschlossen' | 'Offen'} Status text based on completion state
|
||||||
|
*/
|
||||||
|
completed = computed(() => {
|
||||||
|
const returnData = this.remissionReturn();
|
||||||
|
|
||||||
|
if (!returnData.receipts || returnData.receipts.length === 0) {
|
||||||
|
return 'Offen';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCompletedReceipt = returnData.receipts.some(
|
||||||
|
(receipt) => receipt.data?.completed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasCompletedReceipt ? 'Abgeschlossen' : 'Offen';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for completed for backward compatibility with tests.
|
||||||
|
* @deprecated Use completed() instead
|
||||||
|
*/
|
||||||
|
status = this.completed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { RemissionReturnReceiptListComponent } from './remission-return-receipt-list.component';
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: RemissionReturnReceiptListComponent,
|
||||||
|
data: {
|
||||||
|
scrollPositionRestoration: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':returnId/:receiptId',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('@isa/remission/feature/remission-return-receipt-details').then(
|
||||||
|
(m) => m.RemissionReturnReceiptDetailsComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import '@analogjs/vitest-angular/setup-zone';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting,
|
||||||
|
} from '@angular/platform-browser/testing';
|
||||||
|
import { getTestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock URL.createObjectURL to prevent scanner service errors
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn()
|
||||||
|
});
|
||||||
|
|
||||||
|
getTestBed().initTestEnvironment(
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting(),
|
||||||
|
);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"importHelpers": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"typeCheckHostBindings": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.lib.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../../dist/out-tsc",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/test-setup.ts",
|
||||||
|
"jest.config.ts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx"
|
||||||
|
],
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../../../dist/out-tsc",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals",
|
||||||
|
"vitest/importMeta",
|
||||||
|
"vite/client",
|
||||||
|
"node",
|
||||||
|
"vitest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vite.config.mts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.config.mts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.js",
|
||||||
|
"src/**/*.spec.js",
|
||||||
|
"src/**/*.test.jsx",
|
||||||
|
"src/**/*.spec.jsx",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
],
|
||||||
|
"files": ["src/test-setup.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// <reference types='vitest' />
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import angular from '@analogjs/vite-plugin-angular';
|
||||||
|
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||||
|
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
root: __dirname,
|
||||||
|
cacheDir:
|
||||||
|
'../../../../node_modules/.vite/libs/remission/feature/remission-return-receipt-list',
|
||||||
|
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
|
// Uncomment this if you are using workers.
|
||||||
|
// worker: {
|
||||||
|
// plugins: [ nxViteTsPaths() ],
|
||||||
|
// },
|
||||||
|
test: {
|
||||||
|
watch: false,
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
setupFiles: ['src/test-setup.ts'],
|
||||||
|
reporters: ['default'],
|
||||||
|
coverage: {
|
||||||
|
reportsDirectory:
|
||||||
|
'../../../../coverage/libs/remission/feature/remission-return-receipt-list',
|
||||||
|
provider: 'v8' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,339 +1,345 @@
|
|||||||
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { ProductStockInfoComponent } from './product-stock-info.component';
|
import { ProductStockInfoComponent } from './product-stock-info.component';
|
||||||
|
|
||||||
describe('ProductStockInfoComponent', () => {
|
describe('ProductStockInfoComponent', () => {
|
||||||
let spectator: Spectator<ProductStockInfoComponent>;
|
let component: ProductStockInfoComponent;
|
||||||
const createComponent = createComponentFactory(ProductStockInfoComponent);
|
let fixture: ComponentFixture<ProductStockInfoComponent>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
spectator = createComponent();
|
await TestBed.configureTestingModule({
|
||||||
});
|
imports: [ProductStockInfoComponent],
|
||||||
|
}).compileComponents();
|
||||||
it('should create', () => {
|
|
||||||
expect(spectator.component).toBeTruthy();
|
fixture = TestBed.createComponent(ProductStockInfoComponent);
|
||||||
});
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
it('should display the current stock', () => {
|
|
||||||
// Arrange
|
it('should create', () => {
|
||||||
spectator.setInput('stock', 42);
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
// Act
|
|
||||||
spectator.detectChanges();
|
it('should display the current stock', () => {
|
||||||
const value = spectator.query(
|
// Arrange
|
||||||
'[data-what="stock-value"][data-which="current-stock"]',
|
fixture.componentRef.setInput('stock', 42);
|
||||||
);
|
|
||||||
|
// Act
|
||||||
// Assert
|
fixture.detectChanges();
|
||||||
expect(value).toHaveText('42x');
|
const value = fixture.nativeElement.querySelector(
|
||||||
});
|
'[data-what="stock-value"][data-which="current-stock"]',
|
||||||
|
);
|
||||||
it('should display the remit amount (computed)', () => {
|
|
||||||
// Arrange
|
// Assert
|
||||||
spectator.setInput('stock', 20);
|
expect(value?.textContent?.trim()).toBe('42x');
|
||||||
spectator.setInput('removedFromStock', 5);
|
});
|
||||||
spectator.setInput('remainingQuantityInStock', 10);
|
|
||||||
|
it('should display the remit amount (computed)', () => {
|
||||||
// Act
|
// Arrange
|
||||||
spectator.detectChanges();
|
fixture.componentRef.setInput('stock', 20);
|
||||||
const value = spectator.query(
|
fixture.componentRef.setInput('removedFromStock', 5);
|
||||||
'[data-what="stock-value"][data-which="remit-amount"]',
|
fixture.componentRef.setInput('remainingQuantityInStock', 10);
|
||||||
);
|
|
||||||
|
// Act
|
||||||
// Assert
|
fixture.detectChanges();
|
||||||
// availableStock = 20 - 5 = 15; stockToRemit = 15 - 10 = 5
|
const value = fixture.nativeElement.querySelector(
|
||||||
expect(value).toHaveText('5x');
|
'[data-what="stock-value"][data-which="remit-amount"]',
|
||||||
});
|
);
|
||||||
|
|
||||||
it('should display the remit amount as 0 when remainingQuantityInStock > availableStock', () => {
|
// Assert
|
||||||
// Arrange
|
// availableStock = 20 - 5 = 15; stockToRemit = 15 - 10 = 5
|
||||||
spectator.setInput('stock', 5);
|
expect(value?.textContent?.trim()).toBe('5x');
|
||||||
spectator.setInput('removedFromStock', 2);
|
});
|
||||||
spectator.setInput('remainingQuantityInStock', 10);
|
|
||||||
|
it('should display the remit amount as 0 when remainingQuantityInStock > availableStock', () => {
|
||||||
// Act
|
// Arrange
|
||||||
spectator.detectChanges();
|
fixture.componentRef.setInput('stock', 5);
|
||||||
const value = spectator.query(
|
fixture.componentRef.setInput('removedFromStock', 2);
|
||||||
'[data-what="stock-value"][data-which="remit-amount"]',
|
fixture.componentRef.setInput('remainingQuantityInStock', 10);
|
||||||
);
|
|
||||||
|
// Act
|
||||||
// Assert
|
fixture.detectChanges();
|
||||||
expect(value).toHaveText('0x');
|
const value = fixture.nativeElement.querySelector(
|
||||||
});
|
'[data-what="stock-value"][data-which="remit-amount"]',
|
||||||
|
);
|
||||||
it('should display the remaining stock (targetStock, computed)', () => {
|
|
||||||
// Arrange
|
// Assert
|
||||||
spectator.setInput('stock', 20);
|
expect(value?.textContent?.trim()).toBe('0x');
|
||||||
spectator.setInput('removedFromStock', 5);
|
});
|
||||||
spectator.setInput('predefinedReturnQuantity', 5);
|
|
||||||
|
it('should display the remaining stock (targetStock, computed)', () => {
|
||||||
// Act
|
// Arrange
|
||||||
spectator.detectChanges();
|
fixture.componentRef.setInput('stock', 20);
|
||||||
const value = spectator.query(
|
fixture.componentRef.setInput('removedFromStock', 5);
|
||||||
'[data-what="stock-value"][data-which="remaining-stock"]',
|
fixture.componentRef.setInput('predefinedReturnQuantity', 5);
|
||||||
);
|
|
||||||
|
// Act
|
||||||
// Assert
|
fixture.detectChanges();
|
||||||
// availableStock = 20 - 5 = 15; targetStock = 15 - 5 = 10
|
const value = fixture.nativeElement.querySelector(
|
||||||
expect(value).toHaveText('10x');
|
'[data-what="stock-value"][data-which="remaining-stock"]',
|
||||||
});
|
);
|
||||||
|
|
||||||
it('should display the remaining stock as 0 when predefinedReturnQuantity > availableStock', () => {
|
// Assert
|
||||||
// Arrange
|
// availableStock = 20 - 5 = 15; targetStock = 15 - 5 = 10
|
||||||
spectator.setInput('stock', 8);
|
expect(value?.textContent?.trim()).toBe('10x');
|
||||||
spectator.setInput('removedFromStock', 3);
|
});
|
||||||
spectator.setInput('predefinedReturnQuantity', 10);
|
|
||||||
|
it('should display the remaining stock as 0 when predefinedReturnQuantity > availableStock', () => {
|
||||||
// Act
|
// Arrange
|
||||||
spectator.detectChanges();
|
fixture.componentRef.setInput('stock', 8);
|
||||||
const value = spectator.query(
|
fixture.componentRef.setInput('removedFromStock', 3);
|
||||||
'[data-what="stock-value"][data-which="remaining-stock"]',
|
fixture.componentRef.setInput('predefinedReturnQuantity', 10);
|
||||||
);
|
|
||||||
|
// Act
|
||||||
// Assert
|
fixture.detectChanges();
|
||||||
// availableStock = 8 - 3 = 5; targetStock = 5 - 10 = -5 => 0
|
const value = fixture.nativeElement.querySelector(
|
||||||
expect(value).toHaveText('0x');
|
'[data-what="stock-value"][data-which="remaining-stock"]',
|
||||||
});
|
);
|
||||||
|
|
||||||
it('should display the zob value', () => {
|
// Assert
|
||||||
// Arrange
|
// availableStock = 8 - 3 = 5; targetStock = 5 - 10 = -5 => 0
|
||||||
spectator.setInput('zob', 99);
|
expect(value?.textContent?.trim()).toBe('0x');
|
||||||
|
});
|
||||||
// Act
|
|
||||||
spectator.detectChanges();
|
it('should display the zob value', () => {
|
||||||
const value = spectator.query(
|
// Arrange
|
||||||
'[data-what="stock-value"][data-which="zob"]',
|
fixture.componentRef.setInput('zob', 99);
|
||||||
);
|
|
||||||
|
// Act
|
||||||
// Assert
|
fixture.detectChanges();
|
||||||
expect(value).toHaveText('99x');
|
const value = fixture.nativeElement.querySelector(
|
||||||
});
|
'[data-what="stock-value"][data-which="zob"]',
|
||||||
|
);
|
||||||
it('should render all labels with correct e2e attributes', () => {
|
|
||||||
// Arrange
|
// Assert
|
||||||
const labels = [
|
expect(value?.textContent?.trim()).toBe('99x');
|
||||||
{ which: 'current-stock', text: 'Aktueller Bestand' },
|
});
|
||||||
{ which: 'remit-amount', text: 'Remi Menge' },
|
|
||||||
{ which: 'remaining-stock', text: 'Übriger Bestand' },
|
it('should render all labels with correct e2e attributes', () => {
|
||||||
{ which: 'zob', text: 'ZOB' },
|
// Arrange
|
||||||
];
|
const labels = [
|
||||||
|
{ which: 'current-stock', text: 'Aktueller Bestand' },
|
||||||
// Act & Assert
|
{ which: 'remit-amount', text: 'Remi Menge' },
|
||||||
labels.forEach(({ which, text }) => {
|
{ which: 'remaining-stock', text: 'Übriger Bestand' },
|
||||||
const label = spectator.query(
|
{ which: 'zob', text: 'ZOB' },
|
||||||
`[data-what="stock-label"][data-which="${which}"]`,
|
];
|
||||||
);
|
|
||||||
expect(label).toHaveText(text);
|
// Act
|
||||||
});
|
fixture.detectChanges();
|
||||||
});
|
|
||||||
|
// Assert
|
||||||
it('should compute availableStock correctly (stock > removedFromStock)', () => {
|
labels.forEach(({ which, text }) => {
|
||||||
// Arrange
|
const label = fixture.nativeElement.querySelector(
|
||||||
spectator.setInput('stock', 10);
|
`[data-what="stock-label"][data-which="${which}"]`,
|
||||||
spectator.setInput('removedFromStock', 3);
|
);
|
||||||
|
expect(label?.textContent?.trim()).toBe(text);
|
||||||
// Act
|
});
|
||||||
const result = spectator.component.availableStock();
|
});
|
||||||
|
|
||||||
// Assert
|
it('should compute availableStock correctly (stock > removedFromStock)', () => {
|
||||||
expect(result).toBe(7);
|
// Arrange
|
||||||
});
|
fixture.componentRef.setInput('stock', 10);
|
||||||
|
fixture.componentRef.setInput('removedFromStock', 3);
|
||||||
it('should compute availableStock as 0 when removedFromStock > stock', () => {
|
|
||||||
// Arrange
|
// Act
|
||||||
spectator.setInput('stock', 5);
|
const result = component.availableStock();
|
||||||
spectator.setInput('removedFromStock', 10);
|
|
||||||
|
// Assert
|
||||||
// Act
|
expect(result).toBe(7);
|
||||||
const result = spectator.component.availableStock();
|
});
|
||||||
|
|
||||||
// Assert
|
it('should compute availableStock as 0 when removedFromStock > stock', () => {
|
||||||
expect(result).toBe(0);
|
// Arrange
|
||||||
});
|
fixture.componentRef.setInput('stock', 5);
|
||||||
|
fixture.componentRef.setInput('removedFromStock', 10);
|
||||||
it('should compute stockToRemit correctly (positive result)', () => {
|
|
||||||
// Arrange
|
// Act
|
||||||
spectator.setInput('stock', 20);
|
const result = component.availableStock();
|
||||||
spectator.setInput('removedFromStock', 5);
|
|
||||||
spectator.setInput('remainingQuantityInStock', 10);
|
// Assert
|
||||||
|
expect(result).toBe(0);
|
||||||
// Act
|
});
|
||||||
const result = spectator.component.stockToRemit();
|
|
||||||
|
it('should compute stockToRemit correctly (positive result)', () => {
|
||||||
// Assert
|
// Arrange
|
||||||
// availableStock = 20 - 5 = 15; stockToRemit = 15 - 10 = 5
|
fixture.componentRef.setInput('stock', 20);
|
||||||
expect(result).toBe(5);
|
fixture.componentRef.setInput('removedFromStock', 5);
|
||||||
});
|
fixture.componentRef.setInput('remainingQuantityInStock', 10);
|
||||||
|
|
||||||
it('should compute stockToRemit as 0 when remainingQuantityInStock > availableStock', () => {
|
// Act
|
||||||
// Arrange
|
const result = component.stockToRemit();
|
||||||
spectator.setInput('stock', 5);
|
|
||||||
spectator.setInput('removedFromStock', 2);
|
// Assert
|
||||||
spectator.setInput('remainingQuantityInStock', 10);
|
// availableStock = 20 - 5 = 15; stockToRemit = 15 - 10 = 5
|
||||||
|
expect(result).toBe(5);
|
||||||
// Act
|
});
|
||||||
const result = spectator.component.stockToRemit();
|
|
||||||
|
it('should compute stockToRemit as 0 when remainingQuantityInStock > availableStock', () => {
|
||||||
// Assert
|
// Arrange
|
||||||
// availableStock = 5 - 2 = 3; stockToRemit = 3 - 10 = -7 => 0
|
fixture.componentRef.setInput('stock', 5);
|
||||||
expect(result).toBe(0);
|
fixture.componentRef.setInput('removedFromStock', 2);
|
||||||
});
|
fixture.componentRef.setInput('remainingQuantityInStock', 10);
|
||||||
|
|
||||||
it('should compute targetStock correctly (positive result)', () => {
|
// Act
|
||||||
// Arrange
|
const result = component.stockToRemit();
|
||||||
spectator.setInput('stock', 30);
|
|
||||||
spectator.setInput('removedFromStock', 5);
|
// Assert
|
||||||
spectator.setInput('predefinedReturnQuantity', 10);
|
// availableStock = 5 - 2 = 3; stockToRemit = 3 - 10 = -7 => 0
|
||||||
|
expect(result).toBe(0);
|
||||||
// Act
|
});
|
||||||
const result = spectator.component.targetStock();
|
|
||||||
|
it('should compute targetStock correctly (positive result)', () => {
|
||||||
// Assert
|
// Arrange
|
||||||
// availableStock = 30 - 5 = 25; targetStock = 25 - 10 = 15
|
fixture.componentRef.setInput('stock', 30);
|
||||||
expect(result).toBe(15);
|
fixture.componentRef.setInput('removedFromStock', 5);
|
||||||
});
|
fixture.componentRef.setInput('predefinedReturnQuantity', 10);
|
||||||
|
|
||||||
it('should compute targetStock as 0 when predefinedReturnQuantity > availableStock', () => {
|
// Act
|
||||||
// Arrange
|
const result = component.targetStock();
|
||||||
spectator.setInput('stock', 8);
|
|
||||||
spectator.setInput('removedFromStock', 3);
|
// Assert
|
||||||
spectator.setInput('predefinedReturnQuantity', 10);
|
// availableStock = 30 - 5 = 25; targetStock = 25 - 10 = 15
|
||||||
|
expect(result).toBe(15);
|
||||||
// Act
|
});
|
||||||
const result = spectator.component.targetStock();
|
|
||||||
|
it('should compute targetStock as 0 when predefinedReturnQuantity > availableStock', () => {
|
||||||
// Assert
|
// Arrange
|
||||||
// availableStock = 8 - 3 = 5; targetStock = 5 - 10 = -5 => 0
|
fixture.componentRef.setInput('stock', 8);
|
||||||
expect(result).toBe(0);
|
fixture.componentRef.setInput('removedFromStock', 3);
|
||||||
});
|
fixture.componentRef.setInput('predefinedReturnQuantity', 10);
|
||||||
|
|
||||||
it('should compute targetStock using stockToRemit when remainingQuantityInStock is zero or falsy', () => {
|
// Act
|
||||||
// Arrange
|
const result = component.targetStock();
|
||||||
spectator.setInput('stock', 15);
|
|
||||||
spectator.setInput('removedFromStock', 5);
|
// Assert
|
||||||
spectator.setInput('predefinedReturnQuantity', 0);
|
// availableStock = 8 - 3 = 5; targetStock = 5 - 10 = -5 => 0
|
||||||
spectator.setInput('remainingQuantityInStock', 0);
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
// Act
|
|
||||||
const result = spectator.component.targetStock();
|
it('should compute targetStock using stockToRemit when remainingQuantityInStock is zero or falsy', () => {
|
||||||
|
// Arrange
|
||||||
// Assert
|
fixture.componentRef.setInput('stock', 15);
|
||||||
// availableStock = 15 - 5 = 10
|
fixture.componentRef.setInput('removedFromStock', 5);
|
||||||
// stockToRemit = 10 - 0 = 10
|
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
|
||||||
// targetStock = 10 - 10 = 0
|
fixture.componentRef.setInput('remainingQuantityInStock', 0);
|
||||||
expect(result).toBe(0);
|
|
||||||
});
|
// Act
|
||||||
|
const result = component.targetStock();
|
||||||
it('should compute targetStock using remainingQuantityInStock when it is set (non-zero)', () => {
|
|
||||||
// Arrange
|
// Assert
|
||||||
spectator.setInput('stock', 20);
|
// availableStock = 15 - 5 = 10
|
||||||
spectator.setInput('removedFromStock', 5);
|
// stockToRemit = 10 - 0 = 10
|
||||||
spectator.setInput('remainingQuantityInStock', 7);
|
// targetStock = 10 - 10 = 0
|
||||||
|
expect(result).toBe(0);
|
||||||
// Act
|
});
|
||||||
const result = spectator.component.targetStock();
|
|
||||||
|
it('should compute targetStock using remainingQuantityInStock when it is set (non-zero)', () => {
|
||||||
// Assert
|
// Arrange
|
||||||
// Should return remainingQuantityInStock directly
|
fixture.componentRef.setInput('stock', 20);
|
||||||
expect(result).toBe(7);
|
fixture.componentRef.setInput('removedFromStock', 5);
|
||||||
});
|
fixture.componentRef.setInput('remainingQuantityInStock', 7);
|
||||||
|
|
||||||
it('should compute targetStock as 0 if stockToRemit is greater than availableStock', () => {
|
// Act
|
||||||
// Arrange
|
const result = component.targetStock();
|
||||||
spectator.setInput('stock', 5);
|
|
||||||
spectator.setInput('removedFromStock', 2);
|
// Assert
|
||||||
spectator.setInput('remainingQuantityInStock', 0);
|
// Should return remainingQuantityInStock directly
|
||||||
spectator.setInput('predefinedReturnQuantity', 0);
|
expect(result).toBe(7);
|
||||||
|
});
|
||||||
// Act
|
|
||||||
const result = spectator.component.targetStock();
|
it('should compute targetStock as 0 if stockToRemit is greater than availableStock', () => {
|
||||||
|
// Arrange
|
||||||
// Assert
|
fixture.componentRef.setInput('stock', 5);
|
||||||
// availableStock = 5 - 2 = 3
|
fixture.componentRef.setInput('removedFromStock', 2);
|
||||||
// stockToRemit = 3 - 0 = 3
|
fixture.componentRef.setInput('remainingQuantityInStock', 0);
|
||||||
// targetStock = 3 - 3 = 0
|
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
|
||||||
expect(result).toBe(0);
|
|
||||||
});
|
// Act
|
||||||
|
const result = component.targetStock();
|
||||||
it('should compute stockToRemit as predefinedReturnQuantity if set (non-zero)', () => {
|
|
||||||
// Arrange
|
// Assert
|
||||||
spectator.setInput('stock', 10);
|
// availableStock = 5 - 2 = 3
|
||||||
spectator.setInput('removedFromStock', 2);
|
// stockToRemit = 3 - 0 = 3
|
||||||
spectator.setInput('predefinedReturnQuantity', 4);
|
// targetStock = 3 - 3 = 0
|
||||||
spectator.setInput('remainingQuantityInStock', 5);
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
// Act
|
|
||||||
const result = spectator.component.stockToRemit();
|
it('should compute stockToRemit as predefinedReturnQuantity if set (non-zero)', () => {
|
||||||
|
// Arrange
|
||||||
// Assert
|
fixture.componentRef.setInput('stock', 10);
|
||||||
// Should return predefinedReturnQuantity directly
|
fixture.componentRef.setInput('removedFromStock', 2);
|
||||||
expect(result).toBe(4);
|
fixture.componentRef.setInput('predefinedReturnQuantity', 4);
|
||||||
});
|
fixture.componentRef.setInput('remainingQuantityInStock', 5);
|
||||||
|
|
||||||
it('should compute stockToRemit as 0 if availableStock and remainingQuantityInStock are both zero', () => {
|
// Act
|
||||||
// Arrange
|
const result = component.stockToRemit();
|
||||||
spectator.setInput('stock', 0);
|
|
||||||
spectator.setInput('removedFromStock', 0);
|
// Assert
|
||||||
spectator.setInput('predefinedReturnQuantity', 0);
|
// Should return predefinedReturnQuantity directly
|
||||||
spectator.setInput('remainingQuantityInStock', 0);
|
expect(result).toBe(4);
|
||||||
|
});
|
||||||
// Act
|
|
||||||
const result = spectator.component.stockToRemit();
|
it('should compute stockToRemit as 0 if availableStock and remainingQuantityInStock are both zero', () => {
|
||||||
|
// Arrange
|
||||||
// Assert
|
fixture.componentRef.setInput('stock', 0);
|
||||||
expect(result).toBe(0);
|
fixture.componentRef.setInput('removedFromStock', 0);
|
||||||
});
|
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
|
||||||
|
fixture.componentRef.setInput('remainingQuantityInStock', 0);
|
||||||
it('should handle all-zero inputs for computed properties', () => {
|
|
||||||
// Arrange
|
// Act
|
||||||
spectator.setInput('stock', 0);
|
const result = component.stockToRemit();
|
||||||
spectator.setInput('removedFromStock', 0);
|
|
||||||
spectator.setInput('remainingQuantityInStock', 0);
|
// Assert
|
||||||
spectator.setInput('predefinedReturnQuantity', 0);
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
// Act & Assert
|
|
||||||
expect(spectator.component.availableStock()).toBe(0);
|
it('should handle all-zero inputs for computed properties', () => {
|
||||||
expect(spectator.component.stockToRemit()).toBe(0);
|
// Arrange
|
||||||
expect(spectator.component.targetStock()).toBe(0);
|
fixture.componentRef.setInput('stock', 0);
|
||||||
});
|
fixture.componentRef.setInput('removedFromStock', 0);
|
||||||
|
fixture.componentRef.setInput('remainingQuantityInStock', 0);
|
||||||
it('should display all values as 0x when all inputs are zero', () => {
|
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
|
||||||
// Arrange
|
|
||||||
spectator.setInput('stock', 0);
|
// Act & Assert
|
||||||
spectator.setInput('removedFromStock', 0);
|
expect(component.availableStock()).toBe(0);
|
||||||
spectator.setInput('remainingQuantityInStock', 0);
|
expect(component.stockToRemit()).toBe(0);
|
||||||
spectator.setInput('predefinedReturnQuantity', 0);
|
expect(component.targetStock()).toBe(0);
|
||||||
spectator.setInput('zob', 0);
|
});
|
||||||
|
|
||||||
// Act
|
it('should display all values as 0x when all inputs are zero', () => {
|
||||||
spectator.detectChanges();
|
// Arrange
|
||||||
|
fixture.componentRef.setInput('stock', 0);
|
||||||
// Assert
|
fixture.componentRef.setInput('removedFromStock', 0);
|
||||||
expect(
|
fixture.componentRef.setInput('remainingQuantityInStock', 0);
|
||||||
spectator.query('[data-what="stock-value"][data-which="current-stock"]'),
|
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
|
||||||
).toHaveText('0x');
|
fixture.componentRef.setInput('zob', 0);
|
||||||
expect(
|
|
||||||
spectator.query('[data-what="stock-value"][data-which="remit-amount"]'),
|
// Act
|
||||||
).toHaveText('0x');
|
fixture.detectChanges();
|
||||||
expect(
|
|
||||||
spectator.query(
|
// Assert
|
||||||
'[data-what="stock-value"][data-which="remaining-stock"]',
|
expect(
|
||||||
),
|
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="current-stock"]')?.textContent?.trim()
|
||||||
).toHaveText('0x');
|
).toBe('0x');
|
||||||
expect(
|
expect(
|
||||||
spectator.query('[data-what="stock-value"][data-which="zob"]'),
|
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="remit-amount"]')?.textContent?.trim()
|
||||||
).toHaveText('0x');
|
).toBe('0x');
|
||||||
});
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="remaining-stock"]')?.textContent?.trim()
|
||||||
it('should display correct values when only zob is set', () => {
|
).toBe('0x');
|
||||||
// Arrange
|
expect(
|
||||||
spectator.setInput('zob', 123);
|
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="zob"]')?.textContent?.trim()
|
||||||
|
).toBe('0x');
|
||||||
// Act
|
});
|
||||||
spectator.detectChanges();
|
|
||||||
|
it('should display correct values when only zob is set', () => {
|
||||||
// Assert
|
// Arrange
|
||||||
expect(
|
fixture.componentRef.setInput('zob', 123);
|
||||||
spectator.query('[data-what="stock-value"][data-which="zob"]'),
|
|
||||||
).toHaveText('123x');
|
// Act
|
||||||
});
|
fixture.detectChanges();
|
||||||
});
|
|
||||||
|
// Assert
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="zob"]')?.textContent?.trim()
|
||||||
|
).toBe('123x');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<ui-toolbar>
|
<ui-toolbar>
|
||||||
<span class="text-isa-neutral-600 isa-text-body-2-regular">Sortieren</span>
|
<span class="text-isa-neutral-600 isa-text-body-2-regular">Sortieren</span>
|
||||||
<div class="flex-grow"></div>
|
<div class="flex-grow"></div>
|
||||||
@for (orderBy of orderByOptions(); track orderBy.by) {
|
@for (orderBy of orderByOptions(); track orderBy.by) {
|
||||||
<button
|
<button
|
||||||
class="flex flex-1 gap-1 items-center text-nowrap"
|
class="flex flex-grow-0 gap-1 items-center text-nowrap"
|
||||||
uiTextButton
|
uiTextButton
|
||||||
type="button"
|
type="button"
|
||||||
(click)="toggleOrderBy(orderBy)"
|
(click)="toggleOrderBy(orderBy)"
|
||||||
data-what="sort-button"
|
data-what="sort-button"
|
||||||
[attr.data-which]="orderBy.by"
|
[attr.data-which]="orderBy.by"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{{ orderBy.label }}
|
{{ orderBy.label }}
|
||||||
</div>
|
</div>
|
||||||
@if (orderBy.currentDir) {
|
@if (orderBy.currentDir) {
|
||||||
<ng-icon [name]="orderBy.currentDir" size="1.25rem"></ng-icon>
|
<ng-icon [name]="orderBy.currentDir" size="1.25rem"></ng-icon>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</ui-toolbar>
|
</ui-toolbar>
|
||||||
|
|||||||
7
libs/ui/bullet-list/README.md
Normal file
7
libs/ui/bullet-list/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# ui-bullet-list
|
||||||
|
|
||||||
|
This library was generated with [Nx](https://nx.dev).
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `nx test ui-bullet-list` to execute the unit tests.
|
||||||
34
libs/ui/bullet-list/eslint.config.cjs
Normal file
34
libs/ui/bullet-list/eslint.config.cjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const nx = require('@nx/eslint-plugin');
|
||||||
|
const baseConfig = require('../../../eslint.config.js');
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
...baseConfig,
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'ui',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'ui',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user