Temporal products route added.

This commit is contained in:
tanshu committed 2026-03-01 02:57:01 +00:00
1 parent d6ba3b8e1b
commit 863bd5117b
31 files changed
+1784 -12

No files matched your search

@@ -0,0 +1,27 @@
"""temporal products editing
Revision ID: 82af4dcd0b42
Revises: 5facd5f8a04c
Create Date: 2026-03-01 02:00:58.517967
"""
from alembic import op
from brewman.models.permission import Permission
# revision identifiers, used by Alembic.
revision = "82af4dcd0b42"
down_revision = "5facd5f8a04c"
branch_labels = None
depends_on = None
def upgrade():
op.execute(
Permission.__table__.insert().values(id="eb604d72-8cbc-4fcb-979c-4713eaf34e56", name="Temporal Products")
)
def downgrade():
pass
+2
View File
@@ -48,6 +48,7 @@ from .routers import (
recipe_template,
role,
tag,
temporal_product,
title,
user,
voucher,
@@ -119,6 +120,7 @@ app.include_router(tag.router, prefix="/api/tags", tags=["tags"])
app.include_router(employee.router, prefix="/api/employees", tags=["employees"])
app.include_router(fingerprint.router, prefix="/api/fingerprint", tags=["employees"])
app.include_router(product.router, prefix="/api/products", tags=["products"])
app.include_router(temporal_product.router, prefix="/api/temporal-products", tags=["products"])
app.include_router(product_group.router, prefix="/api/product-groups", tags=["products"])
app.include_router(recipe.router, prefix="/api/recipes", tags=["products"])
app.include_router(recipe_template.router, prefix="/api/recipe-templates", tags=["products"])
+261
View File
@@ -0,0 +1,261 @@
import uuid
from collections import defaultdict
from datetime import date, timedelta
from typing import Annotated
from fastapi import APIRouter, HTTPException, Security, status
from sqlalchemy import delete, nullsfirst, select
from sqlalchemy.orm import Session, contains_eager
from sqlalchemy.sql.functions import count
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionDep
from ..models.account import Account
from ..models.batch import Batch
from ..models.product import Product
from ..models.product_group import ProductGroup
from ..models.product_version import ProductVersion
from ..models.sku_version import SkuVersion
from ..models.stock_keeping_unit import StockKeepingUnit
from ..schemas.account import AccountLink
from ..schemas.product_group import ProductGroupLink
from ..schemas.temporal_product import Product as ProductModel
from ..schemas.temporal_product import StockKeepingUnit as SkuModel
from ..schemas.temporal_product import TemporalProduct
from ..schemas.user_token import UserToken
router = APIRouter()
@router.put("/{id_}", response_model=None)
def update_route(
id_: uuid.UUID,
data: TemporalProduct,
user: Annotated[UserToken, Security(get_user, scopes=["temporal-products"])],
db: SessionDep,
) -> None:
data.products.sort(key=lambda p: (p.valid_from or date.min, p.valid_till or date.max))
data.skus.sort(key=lambda s: (s.valid_from or date.min, s.valid_till or date.max))
check_gaps(data)
product = db.execute(select(Product).where(Product.id == id_)).scalar_one()
for product_v in product.versions:
data_version = next((p for p in data.products if p.version_id == product_v.id), None)
if data_version is None:
# Delete version
db.delete(product_v)
else:
data.products.remove(data_version)
product_v.handle = ProductVersion.slugify(data_version.name)
product_v.name = data_version.name
product_v.description = data_version.description
product_v.fraction_units = data_version.fraction_units
product_v.product_group_id = data_version.product_group.id_
product_v.account_id = Account.all_purchases()
product_v.is_purchased = data_version.is_purchased
product_v.is_sold = data_version.is_sold
product_v.allergen = data_version.allergen
product_v.protein = data_version.protein
product_v.carbohydrate = data_version.carbohydrate
product_v.total_sugar = data_version.total_sugar
product_v.added_sugar = data_version.added_sugar
product_v.total_fat = data_version.total_fat
product_v.saturated_fat = data_version.saturated_fat
product_v.trans_fat = data_version.trans_fat
product_v.cholestrol = data_version.cholestrol
product_v.sodium = data_version.sodium
product_v.msnf = data_version.msnf
product_v.other_solids = data_version.other_solids
product_v.total_solids = data_version.total_solids
product_v.water = data_version.water
product_v.valid_from = data_version.valid_from
product_v.valid_till = data_version.valid_till
skus = db.execute(select(SkuVersion).join(SkuVersion.sku).where(StockKeepingUnit.product_id == id_)).scalars().all()
for sku_v in skus:
data_sku = next((s for s in data.skus if s.version_id == sku_v.id), None)
if data_sku is None:
# Delete sku version
db.delete(sku_v)
else:
data.skus.remove(data_sku)
sku_v.units = data_sku.units
sku_v.fraction = data_sku.fraction
sku_v.product_yield = data_sku.product_yield
sku_v.cost_price = data_sku.cost_price
sku_v.sale_price = data_sku.sale_price
sku_v.valid_from = data_sku.valid_from
sku_v.valid_till = data_sku.valid_till
db.commit()
return
def check_gaps(data: TemporalProduct) -> None:
skus: dict[uuid.UUID, list[SkuModel]] = defaultdict(list)
for sku in data.skus:
skus[sku.id_].append(sku)
for i, p_item in enumerate(data.products[1:], start=1):
if data.products[i - 1].valid_till + timedelta(days=1) != p_item.valid_from: # type: ignore[operator]
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gaps in product versions exist",
)
for sku_list in skus.values():
for i, s_item in enumerate(sku_list[1:], start=1):
if sku_list[i - 1].valid_till + timedelta(days=1) != s_item.valid_from: # type: ignore[operator]
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gaps in sku versions exist",
)
@router.delete("/{id_}", response_model=None)
def delete_route(
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["temporal-products"])], db: SessionDep
) -> None:
is_fixture: bool = db.execute(select(Product.is_fixture).where(Product.id == id_)).scalar_one()
if is_fixture:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="This product is a fixture and cannot be edited or deleted.",
)
invs = db.execute(
select(count(Batch.id)).where(
Batch.sku_id.in_(select(StockKeepingUnit.id).where(StockKeepingUnit.product_id == id_))
)
).scalar_one()
if invs > 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="The cannot delete this product as it was billed",
)
db.execute(
delete(ProductVersion).where(ProductVersion.product_id == id_).execution_options(synchronize_session=False)
)
db.execute(
delete(SkuVersion)
.where(SkuVersion.sku_id.in_(select(StockKeepingUnit.id).where(StockKeepingUnit.product_id == id_)))
.execution_options(synchronize_session=False)
)
db.execute(
delete(StockKeepingUnit).where(StockKeepingUnit.product_id == id_).execution_options(synchronize_session=False)
)
# TODO: Check of recipes
db.execute(delete(Product).where(Product.id == id_).execution_options(synchronize_session=False))
db.commit()
return
@router.get("/list", response_model=list[TemporalProduct])
def show_list(
user: Annotated[UserToken, Security(get_user, scopes=["temporal-products"])], db: SessionDep
) -> list[TemporalProduct]:
return product_list(db)
def product_list(db: Session) -> list[TemporalProduct]:
list_ = (
db.execute(
select(Product)
.join(Product.versions)
.join(ProductVersion.product_group)
.join(ProductVersion.account)
.join(Product.skus)
.join(StockKeepingUnit.versions)
.order_by(ProductGroup.name)
.order_by(Account.name)
.order_by(ProductVersion.name)
.order_by(nullsfirst(ProductVersion.valid_from))
.order_by(nullsfirst(SkuVersion.valid_from))
.options(
contains_eager(Product.versions).contains_eager(ProductVersion.product_group),
contains_eager(Product.versions).contains_eager(ProductVersion.account),
contains_eager(Product.skus).contains_eager(StockKeepingUnit.versions),
)
)
.unique()
.scalars()
.all()
)
return [product_info(item) for item in list_]
@router.get("/{id_}", response_model=TemporalProduct)
def show_id(
id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["products"])], db: SessionDep
) -> TemporalProduct:
item = (
db.execute(
select(Product)
.join(Product.versions)
.join(ProductVersion.product_group)
.join(ProductVersion.account)
.join(Product.skus)
.join(StockKeepingUnit.versions)
.where(Product.id == id_)
.order_by(nullsfirst(ProductVersion.valid_from))
.order_by(nullsfirst(SkuVersion.valid_from))
.options(
contains_eager(Product.versions).contains_eager(ProductVersion.product_group),
contains_eager(Product.versions).contains_eager(ProductVersion.account),
contains_eager(Product.skus).contains_eager(StockKeepingUnit.versions),
)
)
.unique()
.scalars()
.one()
)
return product_info(item)
def product_info(product: Product) -> TemporalProduct:
tp = TemporalProduct(products=[], skus=[])
for version in product.versions:
tp.products.append(
ProductModel(
id_=version.product_id,
version_id=version.id,
handle=version.handle,
description=version.description,
name=version.name,
fraction_units=version.fraction_units,
is_fixture=version.product.is_fixture,
is_purchased=version.is_purchased,
is_sold=version.is_sold,
product_group=ProductGroupLink(id_=version.product_group.id, name=version.product_group.name),
account=AccountLink(id_=version.account.id, name=version.account.name),
allergen=version.allergen,
protein=version.protein,
carbohydrate=version.carbohydrate,
total_sugar=version.total_sugar,
added_sugar=version.added_sugar,
total_fat=version.total_fat,
saturated_fat=version.saturated_fat,
trans_fat=version.trans_fat,
cholestrol=version.cholestrol,
sodium=version.sodium,
msnf=version.msnf,
other_solids=version.other_solids,
total_solids=version.total_solids,
water=version.water,
valid_from=version.valid_from,
valid_till=version.valid_till,
)
)
for sku_v in (sku_v for sku in product.skus for sku_v in sku.versions):
tp.skus.append(
SkuModel(
id_=sku_v.sku_id,
version_id=sku_v.id,
units=sku_v.units,
fraction=sku_v.fraction,
product_yield=sku_v.product_yield,
cost_price=sku_v.cost_price,
sale_price=sku_v.sale_price,
valid_from=sku_v.valid_from,
valid_till=sku_v.valid_till,
)
)
return tp
+1 -1
View File
@@ -18,7 +18,7 @@ class RoleIn(BaseModel):
name: str = Field(..., min_length=1)
permissions: list[PermissionItem]
included_roles: list[RoleItem]
model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)
model_config = ConfigDict(alias_generator=to_camel, str_strip_whitespace=True, populate_by_name=True)
class Role(RoleIn):
+118
View File
@@ -0,0 +1,118 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, SerializationInfo, field_serializer, field_validator
from . import Daf, to_camel
from .account import AccountLink
from .product_group import ProductGroupLink
class Product(BaseModel):
id_: uuid.UUID
version_id: uuid.UUID
is_fixture: bool
name: Annotated[str, Field(min_length=1)]
handle: str
description: str | None
fraction_units: Annotated[str, Field(min_length=1)]
product_group: ProductGroupLink = Field(...)
account: AccountLink
is_purchased: bool
is_sold: bool
allergen: str
protein: Daf
carbohydrate: Daf
total_sugar: Daf
added_sugar: Daf
total_fat: Daf
saturated_fat: Daf
trans_fat: Daf
cholestrol: Daf
sodium: Daf
msnf: Daf
other_solids: Daf
total_solids: Daf
water: Daf
valid_from: date | None = None
valid_till: date | None = None
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
@field_validator("valid_from", mode="before")
@classmethod
def parse_valid_from(cls, value: str | date | None) -> date | None:
if value is None:
return None
if isinstance(value, date):
return value
return datetime.strptime(value, "%d-%b-%Y").date()
@field_serializer("valid_from")
def serialize_valid_from(self, value: date | None, _info: SerializationInfo) -> str | None:
return None if value is None else value.strftime("%d-%b-%Y")
@field_validator("valid_till", mode="before")
@classmethod
def parse_valid_till(cls, value: str | date | None) -> date | None:
if value is None:
return None
if isinstance(value, date):
return value
return datetime.strptime(value, "%d-%b-%Y").date()
@field_serializer("valid_till")
def serialize_valid_till(self, value: date | None, _info: SerializationInfo) -> str | None:
return None if value is None else value.strftime("%d-%b-%Y")
class StockKeepingUnit(BaseModel):
id_: uuid.UUID
version_id: uuid.UUID
units: Annotated[str, Field(min_length=1)]
fraction: Annotated[Daf, Field(ge=Decimal(1), default=Decimal(1))]
product_yield: Annotated[Daf, Field(gt=Decimal(0), le=Decimal(1), default=Decimal(1))]
cost_price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))]
sale_price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))]
valid_from: date | None = None
valid_till: date | None = None
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
@field_validator("valid_from", mode="before")
@classmethod
def parse_valid_from(cls, value: str | date | None) -> date | None:
if value is None:
return None
if isinstance(value, date):
return value
return datetime.strptime(value, "%d-%b-%Y").date()
@field_serializer("valid_from")
def serialize_valid_from(self, value: date | None, _info: SerializationInfo) -> str | None:
return None if value is None else value.strftime("%d-%b-%Y")
@field_validator("valid_till", mode="before")
@classmethod
def parse_valid_till(cls, value: str | date | None) -> date | None:
if value is None:
return None
if isinstance(value, date):
return value
return datetime.strptime(value, "%d-%b-%Y").date()
@field_serializer("valid_till")
def serialize_valid_till(self, value: date | None, _info: SerializationInfo) -> str | None:
return None if value is None else value.strftime("%d-%b-%Y")
class TemporalProduct(BaseModel):
products: list[Product]
skus: list[StockKeepingUnit]
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
+4
View File
@@ -165,6 +165,10 @@ export const routes: Routes = [
path: 'tags',
loadChildren: () => import('./tag/tag.routes').then((mod) => mod.routes),
},
{
path: 'temporal-products',
loadChildren: () => import('./temporal-product/temporal-products.routes').then((mod) => mod.routes),
},
{
path: 'trial-balance',
loadChildren: () => import('./trial-balance/trial-balance.routes').then((mod) => mod.routes),
@@ -37,6 +37,7 @@
<a mat-menu-item routerLink="/non-contract-purchase">Non Contract Purchases</a>
<a mat-menu-item routerLink="/mozimo-product-register">Mozimo Product Register</a>
<a mat-menu-item routerLink="/mozimo-daily-register">Mozimo Daily Register</a>
<a mat-menu-item routerLink="/temporal-products">Temporal Products</a>
</mat-menu>
<button mat-button [matMenuTriggerFor]="productReportMenu">Product Reports</button>
+2 -2
View File
@@ -19,7 +19,7 @@ export class StockKeepingUnit {
export class Product {
id: string | undefined;
code: number;
handle: string;
name: string;
description: string | undefined;
skus: StockKeepingUnit[];
@@ -48,7 +48,7 @@ export class Product {
water: number;
public constructor(init?: Partial<Product>) {
this.code = 0;
this.handle = '';
this.name = '';
this.skus = [];
@@ -2,8 +2,8 @@
<form [formGroup]="form" class="flex-col wrapped">
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Code</mat-label>
<input matInput formControlName="code" />
<mat-label>Handle</mat-label>
<input matInput formControlName="handle" />
</mat-form-field>
</div>
<div class="row-container">
@@ -47,7 +47,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
@ViewChild('nameElement', { static: true }) nameElement!: ElementRef<HTMLInputElement>;
form: FormGroup<{
code: FormControl<string | number>;
handle: FormControl<string>;
name: FormControl<string | null>;
description: FormControl<string | null>;
fractionUnits: FormControl<string | null>;
@@ -89,7 +89,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
constructor() {
this.form = new FormGroup({
code: new FormControl<string | number>({ value: 0, disabled: true }, { nonNullable: true }),
handle: new FormControl<string>({ value: '', disabled: true }, { nonNullable: true }),
name: new FormControl<string | null>(null),
description: new FormControl<string | null>(null),
fractionUnits: new FormControl<string | null>(null),
@@ -138,7 +138,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
item.productGroup = this.productGroups.find((x) => x.id === item.productGroup?.id);
this.item = item;
this.form.setValue({
code: this.item.code || '(Auto)',
handle: this.item.handle || '',
name: this.item.name,
description: this.item.description || '',
fractionUnits: this.item.fractionUnits ?? '',
@@ -51,9 +51,10 @@ export class ProductListDataSource extends DataSource<Product> {
return this.filterValue.split(' ').reduce(
(p: Product[], c: string) =>
p.filter((x) => {
const productString = `${x.code} ${x.name} ${x.productGroup?.name}${x.isPurchased ? ' purchased' : ' made'}${
x.isSold ? 'sold' : 'used'
}${x.isActive ? 'active' : 'deactive'}`.toLowerCase();
const productString =
`${x.handle} ${x.name} ${x.productGroup?.name}${x.isPurchased ? ' purchased' : ' made'}${
x.isSold ? 'sold' : 'used'
}${x.isActive ? 'active' : 'deactive'}`.toLowerCase();
return productString.indexOf(c) !== -1;
}),
Object.assign([], data),
@@ -92,7 +92,7 @@ export class ProductListComponent implements OnInit, AfterViewInit {
exportCsv() {
const headers = {
Code: 'code',
Handle: 'handle',
Name: 'name',
Units: 'units',
Fraction: 'fraction',
@@ -0,0 +1,16 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable } from 'rxjs';
import { Product } from '../temporal-product';
export class TemporalProductDetailProductDatasource extends DataSource<Product> {
constructor(private data: Observable<Product[]>) {
super();
}
connect(): Observable<Product[]> {
return this.data;
}
disconnect() {}
}
@@ -0,0 +1,16 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable } from 'rxjs';
import { StockKeepingUnit } from '../temporal-product';
export class TemporalProductDetailSkuDatasource extends DataSource<StockKeepingUnit> {
constructor(private data: Observable<StockKeepingUnit[]>) {
super();
}
connect(): Observable<StockKeepingUnit[]> {
return this.data;
}
disconnect() {}
}
@@ -0,0 +1,44 @@
.two-col {
display: flex;
gap: 16px;
}
.col {
flex: 1;
min-width: 0;
}
.card {
padding: 12px;
border-radius: 12px;
margin-bottom: 12px;
}
.full-width {
width: 100%;
}
.buttons {
justify-content: flex-end;
gap: 12px;
}
.backend-actions {
margin-top: 16px;
justify-content: flex-end;
gap: 12px;
}
.nutrition-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
align-items: stretch;
justify-items: stretch;
}
.nutrition-grid > * {
width: 100%;
box-sizing: border-box;
/* helps with padding */
}
@@ -0,0 +1,288 @@
<h2>Product</h2>
<div class="two-col">
<div class="col">
<h2>Product Versions</h2>
<form [formGroup]="productForm" class="flex-col">
<!-- handle / name / fraction units -->
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Handle</mat-label>
<input matInput formControlName="handle" />
</mat-form-field>
</div>
<div class="row-container">
<mat-form-field class="flex-auto basis-3-4">
<mat-label>Name</mat-label>
<input matInput #name formControlName="name" />
</mat-form-field>
<mat-form-field class="flex-auto basis-1-4">
<mat-label>Fraction Units</mat-label>
<input matInput formControlName="fractionUnits" />
</mat-form-field>
</div>
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Description</mat-label>
<input matInput formControlName="description" />
</mat-form-field>
</div>
<div class="row-container">
<mat-checkbox formControlName="isPurchased" class="flex-auto">Purchased?</mat-checkbox>
<mat-checkbox formControlName="isSold" class="flex-auto">Sold?</mat-checkbox>
</div>
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Product Group</mat-label>
<mat-select formControlName="productGroup">
@for (pg of productGroups; track pg) {
<mat-option [value]="pg">
{{ pg.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Allergen</mat-label>
<input matInput formControlName="allergen" />
</mat-form-field>
</div>
<!-- nutritional info shows based on currently selected group in the form -->
@if (productForm.get('productGroup')?.value?.nutritional ?? false) {
<h3>Nutritional Information</h3>
<div class="nutrition-grid">
<mat-form-field class="flex-auto">
<mat-label>Protein</mat-label>
<input matInput formControlName="protein" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Carbohydrate</mat-label>
<input matInput formControlName="carbohydrate" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Total Sugar</mat-label>
<input matInput formControlName="totalSugar" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Added Sugar</mat-label>
<input matInput formControlName="addedSugar" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Total Fat</mat-label>
<input matInput formControlName="totalFat" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Saturated Fat</mat-label>
<input matInput formControlName="saturatedFat" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Trans Fat</mat-label>
<input matInput formControlName="transFat" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Cholestrol</mat-label>
<input matInput formControlName="cholestrol" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Sodium</mat-label>
<input matInput formControlName="sodium" />
</mat-form-field>
</div>
}
@if (productForm.get('productGroup')?.value?.iceCream ?? false) {
<h3>Ice Cream Information</h3>
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>MSNF</mat-label>
<input matInput formControlName="msnf" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Other Solids</mat-label>
<input matInput formControlName="otherSolids" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Total Solids</mat-label>
<input matInput formControlName="totalSolids" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Water</mat-label>
<input matInput formControlName="water" />
</mat-form-field>
</div>
}
<div class="row-container">
<mat-form-field class="flex-auto basis-1-2">
<mat-label>Valid From</mat-label>
<input matInput [matDatepicker]="productValidFromPicker" formControlName="validFrom" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="productValidFromPicker"></mat-datepicker-toggle>
<mat-datepicker #productValidFromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field class="flex-auto basis-1-2">
<mat-label>Valid Till</mat-label>
<input matInput [matDatepicker]="productValidTillPicker" formControlName="validTill" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="productValidTillPicker"></mat-datepicker-toggle>
<mat-datepicker #productValidTillPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="row-container buttons">
<button mat-raised-button (click)="updateProduct()" [disabled]="!selectedProduct">Update</button>
</div>
</form>
<table mat-table [dataSource]="productDataSource" class="mat-elevation-z1 full-width">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let p">{{ p.name }}</td>
</ng-container>
<ng-container matColumnDef="fractionUnits">
<th mat-header-cell *matHeaderCellDef>Fraction Units</th>
<td mat-cell *matCellDef="let p">{{ p.fractionUnits }}</td>
</ng-container>
<ng-container matColumnDef="productGroup">
<th mat-header-cell *matHeaderCellDef>Product Group</th>
<td mat-cell *matCellDef="let p">{{ p.productGroup?.name }}</td>
</ng-container>
<ng-container matColumnDef="validFrom">
<th mat-header-cell *matHeaderCellDef>Valid From</th>
<td mat-cell *matCellDef="let p">{{ p.validFrom }}</td>
</ng-container>
<ng-container matColumnDef="validTill">
<th mat-header-cell *matHeaderCellDef>Valid Till</th>
<td mat-cell *matCellDef="let p">{{ p.validTill }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let p">
<button mat-icon-button (click)="editProduct(p)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="deleteProduct(p)">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="productDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: productDisplayedColumns"></tr>
</table>
</div>
<div class="col">
<h2>SKU Versions</h2>
<form [formGroup]="skuForm" class="flex-col">
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Units</mat-label>
<input matInput formControlName="units" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Fraction</mat-label>
<input matInput type="number" formControlName="fraction" />
</mat-form-field>
</div>
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Yield</mat-label>
<input matInput type="number" formControlName="productYield" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Cost Price</mat-label>
<input matInput type="number" formControlName="costPrice" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Sale Price</mat-label>
<input matInput type="number" formControlName="salePrice" />
</mat-form-field>
</div>
<div class="row-container">
<mat-form-field class="flex-auto basis-1-2">
<mat-label>Valid From</mat-label>
<input matInput [matDatepicker]="skuValidFromPicker" formControlName="validFrom" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="skuValidFromPicker"></mat-datepicker-toggle>
<mat-datepicker #skuValidFromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field class="flex-auto basis-1-2">
<mat-label>Valid Till</mat-label>
<input matInput [matDatepicker]="skuValidTillPicker" formControlName="validTill" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="skuValidTillPicker"></mat-datepicker-toggle>
<mat-datepicker #skuValidTillPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="row-container buttons">
<button mat-raised-button (click)="updateSku()" [disabled]="!selectedSku">Update</button>
</div>
</form>
<table mat-table [dataSource]="skuDataSource" class="mat-elevation-z1 full-width">
<ng-container matColumnDef="units">
<th mat-header-cell *matHeaderCellDef>Units</th>
<td mat-cell *matCellDef="let s">{{ s.units }}</td>
</ng-container>
<!-- additional SKU columns -->
<ng-container matColumnDef="fraction">
<th mat-header-cell *matHeaderCellDef>Fraction</th>
<td mat-cell *matCellDef="let s">{{ s.fraction }}</td>
</ng-container>
<ng-container matColumnDef="productYield">
<th mat-header-cell *matHeaderCellDef>Yield</th>
<td mat-cell *matCellDef="let s">{{ s.productYield }}</td>
</ng-container>
<ng-container matColumnDef="costPrice">
<th mat-header-cell *matHeaderCellDef>Cost Price</th>
<td mat-cell *matCellDef="let s">{{ s.costPrice }}</td>
</ng-container>
<ng-container matColumnDef="salePrice">
<th mat-header-cell *matHeaderCellDef>Sale Price</th>
<td mat-cell *matCellDef="let s">{{ s.salePrice }}</td>
</ng-container>
<ng-container matColumnDef="validFrom">
<th mat-header-cell *matHeaderCellDef>Valid From</th>
<td mat-cell *matCellDef="let s">{{ s.validFrom }}</td>
</ng-container>
<ng-container matColumnDef="validTill">
<th mat-header-cell *matHeaderCellDef>Valid Till</th>
<td mat-cell *matCellDef="let s">{{ s.validTill }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let s">
<button mat-icon-button (click)="editSku(s)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="deleteSku(s)">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="skuDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: skuDisplayedColumns"></tr>
</table>
</div>
</div>
<div class="row-container">
<button mat-raised-button color="primary" class="" (click)="update()">Update</button>
<button mat-raised-button color="warn" (click)="confirmDelete()">Delete</button>
</div>
@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TemporalProductDetailComponent } from './temporal-product-detail.component';
describe('TemporalProductDetailComponent', () => {
let component: TemporalProductDetailComponent;
let fixture: ComponentFixture<TemporalProductDetailComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TemporalProductDetailComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TemporalProductDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,432 @@
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatOptionModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import moment from 'moment';
import { BehaviorSubject } from 'rxjs';
import { ProductGroup } from '../../core/product-group';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { Product, StockKeepingUnit, TemporalProduct } from '../temporal-product';
import { TemporalProductService } from '../temporal-product.service';
import { TemporalProductDetailProductDatasource } from './temporal-product-detail-product-datasource';
import { TemporalProductDetailSkuDatasource } from './temporal-product-detail-sku-datasource';
@Component({
selector: 'app-product-detail',
templateUrl: './temporal-product-detail.component.html',
styleUrls: ['./temporal-product-detail.component.css'],
imports: [
MatButtonModule,
MatCheckboxModule,
MatDatepickerModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
MatTableModule,
ReactiveFormsModule,
],
})
export class TemporalProductDetailComponent implements OnInit, AfterViewInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private ser = inject(TemporalProductService);
// columns updated to match new Product/StockKeepingUnit properties
// include handle and active flag in the table so users can distinguish versions
productDisplayedColumns = ['name', 'fractionUnits', 'productGroup', 'validFrom', 'validTill', 'actions'];
skuDisplayedColumns = [
'units',
'fraction',
'productYield',
'costPrice',
'salePrice',
'validFrom',
'validTill',
'actions',
];
@ViewChild('name', { static: true }) nameElement?: ElementRef;
public selectedProduct: Product | null;
public selectedSku: StockKeepingUnit | null;
productForm: FormGroup<{
handle: FormControl<string>;
name: FormControl<string>;
description: FormControl<string>;
fractionUnits: FormControl<string>;
isPurchased: FormControl<boolean>;
isSold: FormControl<boolean>;
productGroup: FormControl<ProductGroup>;
allergen: FormControl<string>;
protein: FormControl<number>;
carbohydrate: FormControl<number>;
totalSugar: FormControl<number>;
addedSugar: FormControl<number>;
totalFat: FormControl<number>;
saturatedFat: FormControl<number>;
transFat: FormControl<number>;
cholestrol: FormControl<number>;
sodium: FormControl<number>;
msnf: FormControl<number>;
otherSolids: FormControl<number>;
totalSolids: FormControl<number>;
water: FormControl<number>;
validFrom: FormControl<Date | null>;
validTill: FormControl<Date | null>;
}>;
skuForm: FormGroup<{
units: FormControl<string>;
fraction: FormControl<number>;
productYield: FormControl<number>;
costPrice: FormControl<number>;
salePrice: FormControl<number>;
validFrom: FormControl<Date | null>;
validTill: FormControl<Date | null>;
}>;
productGroups: ProductGroup[] = [];
item: TemporalProduct = new TemporalProduct();
public products = new BehaviorSubject<Product[]>([]);
public skus = new BehaviorSubject<StockKeepingUnit[]>([]);
productDataSource: TemporalProductDetailProductDatasource = new TemporalProductDetailProductDatasource(this.products);
skuDataSource: TemporalProductDetailSkuDatasource = new TemporalProductDetailSkuDatasource(this.skus);
constructor() {
// Create form and include all additional product properties present on the Product model
this.productForm = new FormGroup({
handle: new FormControl<string>('', { nonNullable: true }),
name: new FormControl<string>('', { nonNullable: true }),
description: new FormControl<string>('', { nonNullable: true }),
fractionUnits: new FormControl<string>('', { nonNullable: true }),
isPurchased: new FormControl<boolean>(true, { nonNullable: true }),
isSold: new FormControl<boolean>(false, { nonNullable: true }),
productGroup: new FormControl<ProductGroup>(new ProductGroup(), { nonNullable: true }),
allergen: new FormControl<string>('', { nonNullable: true }),
protein: new FormControl<number>(0, { nonNullable: true }),
carbohydrate: new FormControl<number>(0, { nonNullable: true }),
totalSugar: new FormControl<number>(0, { nonNullable: true }),
addedSugar: new FormControl<number>(0, { nonNullable: true }),
totalFat: new FormControl<number>(0, { nonNullable: true }),
saturatedFat: new FormControl<number>(0, { nonNullable: true }),
transFat: new FormControl<number>(0, { nonNullable: true }),
cholestrol: new FormControl<number>(0, { nonNullable: true }),
sodium: new FormControl<number>(0, { nonNullable: true }),
msnf: new FormControl<number>(0, { nonNullable: true }),
otherSolids: new FormControl<number>(0, { nonNullable: true }),
totalSolids: new FormControl<number>(0, { nonNullable: true }),
water: new FormControl<number>(0, { nonNullable: true }),
validFrom: new FormControl<Date | null>(null),
validTill: new FormControl<Date | null>(null),
});
this.skuForm = new FormGroup({
units: new FormControl<string>('', { nonNullable: true }),
fraction: new FormControl<number>(1, { nonNullable: true }),
productYield: new FormControl<number>(1, { nonNullable: true }),
costPrice: new FormControl<number>(0, { nonNullable: true }),
salePrice: new FormControl<number>(0, { nonNullable: true }),
validFrom: new FormControl<Date | null>(null),
validTill: new FormControl<Date | null>(null),
});
this.selectedProduct = null;
this.selectedSku = null;
}
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as {
item: TemporalProduct;
productGroups: ProductGroup[];
};
this.productGroups = data.productGroups;
this.item = data.item;
this.skus.next(this.item.skus);
this.products.next(this.item.products);
});
}
private parseDateOrNull(d: string | null | undefined): Date | null {
return !d ? null : moment(d, 'DD-MMM-YYYY').toDate();
}
private formatDateOrNull(d: Date | null | undefined): string | null {
return !d ? null : moment(d).format('DD-MMM-YYYY');
}
ngAfterViewInit() {
setTimeout(() => {
if (this.nameElement !== undefined) {
this.nameElement.nativeElement.focus();
}
}, 0);
}
editProduct(p: Product) {
this.selectedProduct = p;
this.productForm.setValue({
handle: p.handle ?? '',
name: p.name ?? '',
description: p.description ?? '',
fractionUnits: p.fractionUnits ?? '',
isPurchased: p.isPurchased ?? false,
isSold: p.isSold ?? false,
productGroup: this.productGroups.find((x) => x.id === p.productGroup?.id) ?? new ProductGroup(),
allergen: p.allergen ?? '',
protein: p.protein ?? 0,
carbohydrate: p.carbohydrate ?? 0,
totalSugar: p.totalSugar ?? 0,
addedSugar: p.addedSugar ?? 0,
totalFat: p.totalFat ?? 0,
saturatedFat: p.saturatedFat ?? 0,
transFat: p.transFat ?? 0,
cholestrol: p.cholestrol ?? 0,
sodium: p.sodium ?? 0,
msnf: p.msnf ?? 0,
otherSolids: p.otherSolids ?? 0,
totalSolids: p.totalSolids ?? 0,
water: p.water ?? 0,
validFrom: this.parseDateOrNull(p.validFrom),
validTill: this.parseDateOrNull(p.validTill),
});
setTimeout(() => this.nameElement?.nativeElement?.focus?.(), 0);
}
updateProduct() {
if (!this.selectedProduct) {
return;
}
const formModel = this.productForm.value;
const p = this.selectedProduct;
p.handle = formModel.handle ?? '';
p.name = formModel.name ?? '';
p.description = formModel.description ?? '';
p.fractionUnits = formModel.fractionUnits ?? '';
p.isPurchased = formModel.isPurchased ?? false;
p.isSold = formModel.isSold ?? false;
if (p.productGroup === null || p.productGroup === undefined) {
p.productGroup = new ProductGroup();
}
p.productGroup = formModel.productGroup;
p.allergen = formModel.allergen ?? '';
p.protein = formModel.protein ?? 0;
p.carbohydrate = formModel.carbohydrate ?? 0;
p.totalSugar = formModel.totalSugar ?? 0;
p.addedSugar = formModel.addedSugar ?? 0;
p.totalFat = formModel.totalFat ?? 0;
p.saturatedFat = formModel.saturatedFat ?? 0;
p.transFat = formModel.transFat ?? 0;
p.cholestrol = formModel.cholestrol ?? 0;
p.sodium = formModel.sodium ?? 0;
p.msnf = formModel.msnf ?? 0;
p.otherSolids = formModel.otherSolids ?? 0;
p.totalSolids = formModel.totalSolids ?? 0;
p.water = formModel.water ?? 0;
p.validFrom = this.formatDateOrNull(formModel.validFrom);
p.validTill = this.formatDateOrNull(formModel.validTill);
this.selectedProduct = null;
// Reset form
this.productForm.reset({
handle: '',
name: '',
description: '',
fractionUnits: '',
isPurchased: true,
isSold: false,
productGroup: new ProductGroup(),
allergen: '',
protein: 0,
carbohydrate: 0,
totalSugar: 0,
addedSugar: 0,
totalFat: 0,
saturatedFat: 0,
transFat: 0,
cholestrol: 0,
sodium: 0,
msnf: 0,
otherSolids: 0,
totalSolids: 0,
water: 0,
validFrom: null,
validTill: null,
});
}
deleteProduct(p: Product) {
this.item.products.splice(this.item.products.indexOf(p), 1);
this.products.next(this.item.products);
if (!this.selectedProduct) return;
const idx = (this.item.products ?? []).indexOf(this.selectedProduct);
if (idx >= 0) {
this.item.products.splice(idx, 1);
}
this.selectedProduct = null;
// Reset form
this.productForm.reset({
handle: '',
name: '',
description: '',
fractionUnits: '',
isPurchased: true,
isSold: false,
productGroup: new ProductGroup(),
allergen: '',
protein: 0,
carbohydrate: 0,
totalSugar: 0,
addedSugar: 0,
totalFat: 0,
saturatedFat: 0,
transFat: 0,
cholestrol: 0,
sodium: 0,
msnf: 0,
otherSolids: 0,
totalSolids: 0,
water: 0,
validFrom: null,
validTill: null,
});
}
editSku(s: StockKeepingUnit) {
this.selectedSku = s;
this.skuForm.setValue({
units: s.units ?? '',
fraction: s.fraction ?? 1,
productYield: s.productYield ?? 1,
costPrice: s.costPrice ?? 0,
salePrice: s.salePrice ?? 0,
validFrom: this.parseDateOrNull(s.validFrom),
validTill: this.parseDateOrNull(s.validTill),
});
}
updateSku() {
if (!this.selectedSku) return;
const formModel = this.skuForm.value;
const s = this.selectedSku;
s.units = formModel.units ?? '';
s.fraction = formModel.fraction ?? 1;
s.productYield = formModel.productYield ?? 1;
s.costPrice = formModel.costPrice ?? 0;
s.salePrice = formModel.salePrice ?? 0;
s.validFrom = this.formatDateOrNull(formModel.validFrom);
s.validTill = this.formatDateOrNull(formModel.validTill);
this.selectedSku = null;
this.skuForm.reset({
units: '',
fraction: 1,
productYield: 1,
costPrice: 0,
salePrice: 0,
validFrom: null,
validTill: null,
});
}
deleteSku(s: StockKeepingUnit) {
this.item.skus.splice(this.item.skus.indexOf(s), 1);
this.skus.next(this.item.skus);
if (!this.selectedSku) return;
const idx = (this.item.skus ?? []).indexOf(this.selectedSku);
if (idx >= 0) {
this.item.skus.splice(idx, 1);
}
this.selectedSku = null;
// Reset form
this.skuForm.reset({
units: '',
fraction: 1,
productYield: 1,
costPrice: 0,
salePrice: 0,
validFrom: null,
validTill: null,
});
}
update() {
this.ser.update(this.item).subscribe({
next: () => {
this.snackBar.open('', 'Success');
this.router.navigateByUrl('/temporal-products');
},
error: (error) => {
this.snackBar.open(error, 'Error');
},
});
}
delete() {
this.ser.delete(this.item.products[0].id as string).subscribe({
next: () => {
this.snackBar.open('', 'Success');
this.router.navigateByUrl('/temporal-products');
},
error: (error) => {
this.snackBar.open(error, 'Error');
},
});
}
confirmDelete(): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
width: '250px',
data: { title: 'Delete Product?', content: 'Are you sure? This cannot be undone.' },
});
dialogRef.afterClosed().subscribe((result: boolean) => {
if (result) {
this.delete();
}
});
}
}
@@ -0,0 +1,18 @@
import { inject, TestBed } from '@angular/core/testing';
import { TemporalProductListResolverService } from './temporal-product-list-resolver.service';
describe('TemporalProductListResolverService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TemporalProductListResolverService],
});
});
it('should be created', inject(
[TemporalProductListResolverService],
(service: TemporalProductListResolverService) => {
expect(service).toBeTruthy();
},
));
});
@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { TemporalProduct } from './temporal-product';
import { TemporalProductService } from './temporal-product.service';
export const temporalProductListResolver: ResolveFn<TemporalProduct[]> = () => {
return inject(TemporalProductService).list();
};
@@ -0,0 +1,81 @@
import { DataSource } from '@angular/cdk/collections';
import { merge, Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import { TemporalProduct } from '../temporal-product';
export class TemporalProductListDatasource extends DataSource<TemporalProduct> {
public data: TemporalProduct[];
public filteredData: TemporalProduct[];
public search: string;
public productGroup: string;
constructor(
private readonly searchFilter: Observable<string>,
private readonly productGroupFilter: Observable<string>,
private readonly dataObs: Observable<TemporalProduct[]>,
) {
super();
this.data = [];
this.filteredData = [];
this.search = '';
this.productGroup = '';
}
connect(): Observable<TemporalProduct[]> {
const dataMutations = [
this.dataObs.pipe(
tap((x) => {
this.data = x;
}),
),
this.searchFilter.pipe(
tap((x) => {
this.search = x;
}),
),
this.productGroupFilter.pipe(
tap((x) => {
this.productGroup = x;
}),
),
];
return merge(...dataMutations).pipe(
map(() => this.getFilteredData(this.data, this.search, this.productGroup)),
tap((x: TemporalProduct[]) => {
this.filteredData = x;
}),
);
}
disconnect() {}
private getFilteredData(data: TemporalProduct[], search: string, productGroup: string): TemporalProduct[] {
const tokens = (search ?? '').toLowerCase().split(/\s+/).filter(Boolean);
return data.filter((tp: TemporalProduct) => {
search = search.toLowerCase();
const products = tp.products ?? [];
const skus = tp.skus ?? [];
// 1) Search: match ANY product/sku fields
const matchesSearch =
tokens.length === 0 ||
tokens.every(
(token) =>
products.some((p) => {
const hay = `${p.name ?? ''} ${p.fractionUnits ?? ''} ${p.productGroup?.name ?? ''}`.toLowerCase();
return hay.includes(token);
}) ||
skus.some((k) => {
const hay = `${k.units ?? ''}`.toLowerCase();
return hay.includes(token);
}),
);
const matchesProductGroup = !productGroup || products.some((k) => (k.productGroup?.id ?? '') === productGroup);
return matchesSearch && matchesProductGroup;
});
}
}
@@ -0,0 +1,24 @@
.right {
display: flex;
justify-content: flex-end;
}
.material-icons {
vertical-align: middle;
}
.mat-column-name {
margin-right: 4px;
}
.mat-column-price,
.mat-column-menuCategory,
.mat-column-info,
.mat-column-productGroup {
margin-left: 4px;
margin-right: 4px;
}
.mat-column-quantity {
margin-left: 4px;
}
@@ -0,0 +1,65 @@
<h2>Temporal Products</h2>
<form [formGroup]="form" class="flex-col">
<div class="row-container sm:max-lg:flex-col">
<mat-form-field class="flex-auto basis-2-5">
<mat-label>Filter</mat-label>
<input type="text" matInput formControlName="filter" autocomplete="off" />
</mat-form-field>
<mat-form-field class="flex-auto basis-30">
<mat-label>Product Group</mat-label>
<mat-select formControlName="productGroup" (selectionChange)="filterOn($event.value)">
<mat-option>-- All Products --</mat-option>
@for (pg of productGroups; track pg) {
<mat-option [value]="pg.id">
{{ pg.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</form>
<mat-table [dataSource]="dataSource">
<!-- Name Column -->
<ng-container matColumnDef="product">
<mat-header-cell *matHeaderCellDef>Products</mat-header-cell>
<mat-cell *matCellDef="let row">
<ul>
@for (p of row.products; track p) {
<li>
<a [routerLink]="['/temporal-products', p.id]">
<b>{{ p.name }}</b>
</a>
<div class="text-sm">
<b>Valid:</b>
{{ p.validFrom ?? '&#8734;' }}
<mat-icon>linear_scale</mat-icon>
{{ p.validTill ?? '&#8734;' }}
</div>
</li>
}
</ul>
</mat-cell>
</ng-container>
<ng-container matColumnDef="sku">
<mat-header-cell *matHeaderCellDef>Skus</mat-header-cell>
<mat-cell *matCellDef="let row">
<ul>
@for (s of row.skus; track s) {
<li>
{{ s.units }}
</li>
}
</ul>
</mat-cell>
</ng-container>
<!-- Info Column -->
<ng-container matColumnDef="info">
<mat-header-cell *matHeaderCellDef class="center">Details</mat-header-cell>
<mat-cell *matCellDef="let row"> </mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
@@ -0,0 +1,22 @@
import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
import { TemporalProductListComponent } from './temporal-product-list.component';
describe('TemporalProductListComponent', () => {
let component: TemporalProductListComponent;
let fixture: ComponentFixture<TemporalProductListComponent>;
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
imports: [TemporalProductListComponent],
}).compileComponents();
fixture = TestBed.createComponent(TemporalProductListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should compile', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,86 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { BehaviorSubject, Observable } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { ProductGroup } from '../../core/product-group';
import { TemporalProduct } from '../temporal-product';
import { TemporalProductListDatasource } from './temporal-product-list-datasource';
@Component({
selector: 'app-product-list',
templateUrl: './temporal-product-list.component.html',
styleUrls: ['./temporal-product-list.component.css'],
imports: [
CommonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
MatTableModule,
ReactiveFormsModule,
RouterLink,
],
})
export class TemporalProductListComponent implements OnInit {
private route = inject(ActivatedRoute);
searchFilter = new Observable<string>();
productGroupFilter = new BehaviorSubject<string>('');
data: BehaviorSubject<TemporalProduct[]> = new BehaviorSubject<TemporalProduct[]>([]);
dataSource: TemporalProductListDatasource = new TemporalProductListDatasource(
this.searchFilter,
this.productGroupFilter,
this.data,
);
form: FormGroup<{
filter: FormControl<string>;
productGroup: FormControl<string | null>;
}>;
list: TemporalProduct[] = [];
productGroups: ProductGroup[] = [];
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns: string[] = ['product', 'sku', 'info'];
constructor() {
this.form = new FormGroup({
filter: new FormControl<string>('', { nonNullable: true }),
productGroup: new FormControl<string | null>(''),
});
this.data.subscribe((data: TemporalProduct[]) => {
this.list = data;
});
this.searchFilter = this.form.controls.filter.valueChanges.pipe(debounceTime(150), distinctUntilChanged());
}
filterOn(val: string) {
this.productGroupFilter.next(val);
}
ngOnInit() {
this.dataSource = new TemporalProductListDatasource(this.searchFilter, this.productGroupFilter, this.data);
this.route.data.subscribe((value) => {
const data = value as {
list: TemporalProduct[];
productGroups: ProductGroup[];
};
this.loadData(data.list, data.productGroups);
});
}
loadData(list: TemporalProduct[], productGroups: ProductGroup[]) {
this.productGroups = productGroups;
this.data.next(list);
}
}
@@ -0,0 +1,15 @@
import { inject, TestBed } from '@angular/core/testing';
import { TemporalProductResolverService } from './temporal-product-resolver.service';
describe('TemporalProductResolverService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TemporalProductResolverService],
});
});
it('should be created', inject([TemporalProductResolverService], (service: TemporalProductResolverService) => {
expect(service).toBeTruthy();
}));
});
@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { TemporalProduct } from './temporal-product';
import { TemporalProductService } from './temporal-product.service';
export const temporalProductResolver: ResolveFn<TemporalProduct> = (route) => {
const id = route.paramMap.get('id');
return inject(TemporalProductService).get(id as string);
};
@@ -0,0 +1,15 @@
import { inject, TestBed } from '@angular/core/testing';
import { TemporalProductService } from './temporal-product.service';
describe('TemporalProductService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TemporalProductService],
});
});
it('should be created', inject([TemporalProductService], (service: TemporalProductService) => {
expect(service).toBeTruthy();
}));
});
@@ -0,0 +1,44 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ErrorLoggerService } from '../core/error-logger.service';
import { TemporalProduct } from './temporal-product';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
};
const url = '/api/temporal-products';
const serviceName = 'ProductService';
@Injectable({ providedIn: 'root' })
export class TemporalProductService {
private http = inject(HttpClient);
private log = inject(ErrorLoggerService);
get(id: string): Observable<TemporalProduct> {
return this.http
.get<TemporalProduct>(`${url}/${id}`)
.pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<TemporalProduct>;
}
list(): Observable<TemporalProduct[]> {
return this.http
.get<TemporalProduct[]>(`${url}/list`)
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<TemporalProduct[]>;
}
update(product: TemporalProduct): Observable<void> {
return this.http
.put<TemporalProduct>(`${url}/${product.products[0].id}`, product, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable<void>;
}
delete(id: string): Observable<void> {
return this.http
.delete<TemporalProduct>(`${url}/${id}`, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable<void>;
}
}
@@ -0,0 +1,102 @@
import { Account } from '../core/account';
import { ProductGroup } from '../core/product-group';
export class Product {
id: string | undefined;
versionId?: string;
handle: string;
name: string;
description: string | undefined;
fractionUnits: string;
isFixture: boolean;
isPurchased: boolean;
isSold: boolean;
productGroup?: ProductGroup;
account?: Account;
allergen: string;
protein: number;
carbohydrate: number;
totalSugar: number;
addedSugar: number;
totalFat: number;
saturatedFat: number;
transFat: number;
cholestrol: number;
sodium: number;
msnf: number;
otherSolids: number;
totalSolids: number;
water: number;
validFrom: string | null;
validTill: string | null;
public constructor(init?: Partial<Product>) {
this.id = undefined;
this.handle = '';
this.name = '';
this.fractionUnits = '';
this.isFixture = false;
this.isPurchased = true;
this.isSold = false;
this.allergen = '';
this.protein = 0;
this.carbohydrate = 0;
this.totalSugar = 0;
this.addedSugar = 0;
this.totalFat = 0;
this.saturatedFat = 0;
this.transFat = 0;
this.cholestrol = 0;
this.sodium = 0;
this.msnf = 0;
this.otherSolids = 0;
this.totalSolids = 0;
this.water = 0;
this.validFrom = null;
this.validTill = null;
Object.assign(this, init);
}
}
export class StockKeepingUnit {
id: string | undefined;
versionId?: string;
units: string;
fraction: number;
productYield: number;
costPrice: number;
salePrice: number;
validFrom: string | null;
validTill: string | null;
public constructor(init?: Partial<StockKeepingUnit>) {
this.units = '';
this.fraction = 1;
this.productYield = 1;
this.costPrice = 0;
this.salePrice = 0;
this.validFrom = null;
this.validTill = null;
Object.assign(this, init);
}
}
export class TemporalProduct {
products: Product[];
skus: StockKeepingUnit[];
public constructor(init?: Partial<TemporalProduct>) {
this.products = [];
this.skus = [];
Object.assign(this, init);
}
}
@@ -0,0 +1,47 @@
import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service';
import { productGroupListResolver } from '../product-group/product-group-list.resolver';
import { TemporalProductDetailComponent } from './temporal-product-detail/temporal-product-detail.component';
import { temporalProductListResolver } from './temporal-product-list.resolver';
import { TemporalProductListComponent } from './temporal-product-list/temporal-product-list.component';
import { temporalProductResolver } from './temporal-product.resolver';
export const routes: Routes = [
{
path: '',
component: TemporalProductListComponent,
canActivate: [authGuard],
data: {
permission: 'Temporal Products',
},
resolve: {
list: temporalProductListResolver,
productGroups: productGroupListResolver,
},
},
{
path: 'new',
component: TemporalProductDetailComponent,
canActivate: [authGuard],
data: {
permission: 'Temporal Products',
},
resolve: {
item: temporalProductResolver,
productGroups: productGroupListResolver,
},
},
{
path: ':id',
component: TemporalProductDetailComponent,
canActivate: [authGuard],
data: {
permission: 'Temporal Products',
},
resolve: {
item: temporalProductResolver,
productGroups: productGroupListResolver,
},
},
];