Temporal products route added.
This commit is contained in:
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
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+16
@@ -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() {}
|
||||
}
|
||||
+16
@@ -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() {}
|
||||
}
|
||||
+44
@@ -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 */
|
||||
}
|
||||
+288
@@ -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>
|
||||
+24
@@ -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();
|
||||
});
|
||||
});
|
||||
+432
@@ -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();
|
||||
};
|
||||
+81
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+65
@@ -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 ?? '∞' }}
|
||||
<mat-icon>linear_scale</mat-icon>
|
||||
{{ p.validTill ?? '∞' }}
|
||||
</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>
|
||||
+22
@@ -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();
|
||||
});
|
||||
});
|
||||
+86
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user