diff --git a/brewman/alembic/versions/82af4dcd0b42_temporal_products_editing.py b/brewman/alembic/versions/82af4dcd0b42_temporal_products_editing.py new file mode 100644 index 00000000..6bd2483e --- /dev/null +++ b/brewman/alembic/versions/82af4dcd0b42_temporal_products_editing.py @@ -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 diff --git a/brewman/brewman/main.py b/brewman/brewman/main.py index c58269e0..9ceaef89 100644 --- a/brewman/brewman/main.py +++ b/brewman/brewman/main.py @@ -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"]) diff --git a/brewman/brewman/routers/temporal_product.py b/brewman/brewman/routers/temporal_product.py new file mode 100644 index 00000000..702f79aa --- /dev/null +++ b/brewman/brewman/routers/temporal_product.py @@ -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 diff --git a/brewman/brewman/schemas/role.py b/brewman/brewman/schemas/role.py index cdfbd7e3..82d990ad 100644 --- a/brewman/brewman/schemas/role.py +++ b/brewman/brewman/schemas/role.py @@ -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): diff --git a/brewman/brewman/schemas/temporal_product.py b/brewman/brewman/schemas/temporal_product.py new file mode 100644 index 00000000..02dad740 --- /dev/null +++ b/brewman/brewman/schemas/temporal_product.py @@ -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) diff --git a/overlord/src/app/app.routes.ts b/overlord/src/app/app.routes.ts index fe368081..a5f029c9 100644 --- a/overlord/src/app/app.routes.ts +++ b/overlord/src/app/app.routes.ts @@ -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), diff --git a/overlord/src/app/core/nav-bar/nav-bar.component.html b/overlord/src/app/core/nav-bar/nav-bar.component.html index 9c13c04a..d27f7b32 100644 --- a/overlord/src/app/core/nav-bar/nav-bar.component.html +++ b/overlord/src/app/core/nav-bar/nav-bar.component.html @@ -37,6 +37,7 @@ Non Contract Purchases Mozimo Product Register Mozimo Daily Register + Temporal Products diff --git a/overlord/src/app/core/product.ts b/overlord/src/app/core/product.ts index f0fefe4b..653a8940 100644 --- a/overlord/src/app/core/product.ts +++ b/overlord/src/app/core/product.ts @@ -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) { - this.code = 0; + this.handle = ''; this.name = ''; this.skus = []; diff --git a/overlord/src/app/product/product-detail/product-detail.component.html b/overlord/src/app/product/product-detail/product-detail.component.html index ca9b92eb..71c84154 100644 --- a/overlord/src/app/product/product-detail/product-detail.component.html +++ b/overlord/src/app/product/product-detail/product-detail.component.html @@ -2,8 +2,8 @@
- Code - + Handle +
diff --git a/overlord/src/app/product/product-detail/product-detail.component.ts b/overlord/src/app/product/product-detail/product-detail.component.ts index f557f944..a55675af 100644 --- a/overlord/src/app/product/product-detail/product-detail.component.ts +++ b/overlord/src/app/product/product-detail/product-detail.component.ts @@ -47,7 +47,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { @ViewChild('nameElement', { static: true }) nameElement!: ElementRef; form: FormGroup<{ - code: FormControl; + handle: FormControl; name: FormControl; description: FormControl; fractionUnits: FormControl; @@ -89,7 +89,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { constructor() { this.form = new FormGroup({ - code: new FormControl({ value: 0, disabled: true }, { nonNullable: true }), + handle: new FormControl({ value: '', disabled: true }, { nonNullable: true }), name: new FormControl(null), description: new FormControl(null), fractionUnits: new FormControl(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 ?? '', diff --git a/overlord/src/app/product/product-list/product-list-datasource.ts b/overlord/src/app/product/product-list/product-list-datasource.ts index c7ee9382..4d94c5b4 100644 --- a/overlord/src/app/product/product-list/product-list-datasource.ts +++ b/overlord/src/app/product/product-list/product-list-datasource.ts @@ -51,9 +51,10 @@ export class ProductListDataSource extends DataSource { 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), diff --git a/overlord/src/app/product/product-list/product-list.component.ts b/overlord/src/app/product/product-list/product-list.component.ts index 4d2d8474..6b28a23d 100644 --- a/overlord/src/app/product/product-list/product-list.component.ts +++ b/overlord/src/app/product/product-list/product-list.component.ts @@ -92,7 +92,7 @@ export class ProductListComponent implements OnInit, AfterViewInit { exportCsv() { const headers = { - Code: 'code', + Handle: 'handle', Name: 'name', Units: 'units', Fraction: 'fraction', diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-product-datasource.ts b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-product-datasource.ts new file mode 100644 index 00000000..d967e58d --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-product-datasource.ts @@ -0,0 +1,16 @@ +import { DataSource } from '@angular/cdk/collections'; +import { Observable } from 'rxjs'; + +import { Product } from '../temporal-product'; + +export class TemporalProductDetailProductDatasource extends DataSource { + constructor(private data: Observable) { + super(); + } + + connect(): Observable { + return this.data; + } + + disconnect() {} +} diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-sku-datasource.ts b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-sku-datasource.ts new file mode 100644 index 00000000..b6613695 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-sku-datasource.ts @@ -0,0 +1,16 @@ +import { DataSource } from '@angular/cdk/collections'; +import { Observable } from 'rxjs'; + +import { StockKeepingUnit } from '../temporal-product'; + +export class TemporalProductDetailSkuDatasource extends DataSource { + constructor(private data: Observable) { + super(); + } + + connect(): Observable { + return this.data; + } + + disconnect() {} +} diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css new file mode 100644 index 00000000..8967288e --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css @@ -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 */ +} diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html new file mode 100644 index 00000000..936be6b4 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html @@ -0,0 +1,288 @@ +

Product

+
+
+

Product Versions

+ + +
+ + Handle + + +
+ +
+ + Name + + + + Fraction Units + + +
+ +
+ + Description + + +
+ +
+ Purchased? + Sold? +
+ +
+ + Product Group + + @for (pg of productGroups; track pg) { + + {{ pg.name }} + + } + + +
+ +
+ + Allergen + + +
+ + + @if (productForm.get('productGroup')?.value?.nutritional ?? false) { +

Nutritional Information

+
+ + Protein + + + + Carbohydrate + + + + Total Sugar + + + + Added Sugar + + + + Total Fat + + + + Saturated Fat + + + + Trans Fat + + + + Cholestrol + + + + Sodium + + +
+ } + + @if (productForm.get('productGroup')?.value?.iceCream ?? false) { +

Ice Cream Information

+
+ + MSNF + + + + Other Solids + + + + Total Solids + + + + Water + + +
+ } + +
+ + Valid From + + + + + + Valid Till + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ p.name }}Fraction Units{{ p.fractionUnits }}Product Group{{ p.productGroup?.name }}Valid From{{ p.validFrom }}Valid Till{{ p.validTill }}Actions + + +
+
+
+

SKU Versions

+
+
+ + Units + + + + + Fraction + + +
+ +
+ + Yield + + + + + Cost Price + + + + + Sale Price + + +
+
+ + Valid From + + + + + + Valid Till + + + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Units{{ s.units }}Fraction{{ s.fraction }}Yield{{ s.productYield }}Cost Price{{ s.costPrice }}Sale Price{{ s.salePrice }}Valid From{{ s.validFrom }}Valid Till{{ s.validTill }}Actions + + +
+
+
+
+ + +
diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.spec.ts b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.spec.ts new file mode 100644 index 00000000..0fd07334 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.spec.ts @@ -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; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TemporalProductDetailComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TemporalProductDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts new file mode 100644 index 00000000..bf801369 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts @@ -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; + name: FormControl; + description: FormControl; + fractionUnits: FormControl; + + isPurchased: FormControl; + isSold: FormControl; + productGroup: FormControl; + + allergen: FormControl; + protein: FormControl; + carbohydrate: FormControl; + totalSugar: FormControl; + addedSugar: FormControl; + totalFat: FormControl; + saturatedFat: FormControl; + transFat: FormControl; + cholestrol: FormControl; + sodium: FormControl; + + msnf: FormControl; + otherSolids: FormControl; + totalSolids: FormControl; + water: FormControl; + + validFrom: FormControl; + validTill: FormControl; + }>; + + skuForm: FormGroup<{ + units: FormControl; + fraction: FormControl; + productYield: FormControl; + costPrice: FormControl; + salePrice: FormControl; + validFrom: FormControl; + validTill: FormControl; + }>; + + productGroups: ProductGroup[] = []; + item: TemporalProduct = new TemporalProduct(); + public products = new BehaviorSubject([]); + public skus = new BehaviorSubject([]); + 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('', { nonNullable: true }), + name: new FormControl('', { nonNullable: true }), + description: new FormControl('', { nonNullable: true }), + fractionUnits: new FormControl('', { nonNullable: true }), + + isPurchased: new FormControl(true, { nonNullable: true }), + isSold: new FormControl(false, { nonNullable: true }), + productGroup: new FormControl(new ProductGroup(), { nonNullable: true }), + + allergen: new FormControl('', { nonNullable: true }), + protein: new FormControl(0, { nonNullable: true }), + carbohydrate: new FormControl(0, { nonNullable: true }), + totalSugar: new FormControl(0, { nonNullable: true }), + addedSugar: new FormControl(0, { nonNullable: true }), + totalFat: new FormControl(0, { nonNullable: true }), + saturatedFat: new FormControl(0, { nonNullable: true }), + transFat: new FormControl(0, { nonNullable: true }), + cholestrol: new FormControl(0, { nonNullable: true }), + sodium: new FormControl(0, { nonNullable: true }), + + msnf: new FormControl(0, { nonNullable: true }), + otherSolids: new FormControl(0, { nonNullable: true }), + totalSolids: new FormControl(0, { nonNullable: true }), + water: new FormControl(0, { nonNullable: true }), + + validFrom: new FormControl(null), + validTill: new FormControl(null), + }); + this.skuForm = new FormGroup({ + units: new FormControl('', { nonNullable: true }), + fraction: new FormControl(1, { nonNullable: true }), + productYield: new FormControl(1, { nonNullable: true }), + costPrice: new FormControl(0, { nonNullable: true }), + salePrice: new FormControl(0, { nonNullable: true }), + + validFrom: new FormControl(null), + validTill: new FormControl(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(); + } + }); + } +} diff --git a/overlord/src/app/temporal-product/temporal-product-list.resolver.spec.ts b/overlord/src/app/temporal-product/temporal-product-list.resolver.spec.ts new file mode 100644 index 00000000..250290ad --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-list.resolver.spec.ts @@ -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(); + }, + )); +}); diff --git a/overlord/src/app/temporal-product/temporal-product-list.resolver.ts b/overlord/src/app/temporal-product/temporal-product-list.resolver.ts new file mode 100644 index 00000000..52e43edd --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-list.resolver.ts @@ -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 = () => { + return inject(TemporalProductService).list(); +}; diff --git a/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts new file mode 100644 index 00000000..1b80f6b5 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts @@ -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 { + public data: TemporalProduct[]; + public filteredData: TemporalProduct[]; + public search: string; + public productGroup: string; + + constructor( + private readonly searchFilter: Observable, + private readonly productGroupFilter: Observable, + private readonly dataObs: Observable, + ) { + super(); + this.data = []; + this.filteredData = []; + this.search = ''; + this.productGroup = ''; + } + + connect(): Observable { + 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; + }); + } +} diff --git a/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.css b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.css new file mode 100644 index 00000000..17533808 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.css @@ -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; +} diff --git a/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html new file mode 100644 index 00000000..450ddcec --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html @@ -0,0 +1,65 @@ +

Temporal Products

+
+
+ + Filter + + + + Product Group + + -- All Products -- + @for (pg of productGroups; track pg) { + + {{ pg.name }} + + } + + +
+
+ + + + Products + +
    + @for (p of row.products; track p) { +
  • + + {{ p.name }} + +
    + Valid: + {{ p.validFrom ?? '∞' }} + linear_scale + {{ p.validTill ?? '∞' }} +
    +
  • + } +
+
+
+ + + Skus + +
    + @for (s of row.skus; track s) { +
  • + {{ s.units }} +
  • + } +
+
+
+ + + + Details + + + + + +
diff --git a/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.spec.ts b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.spec.ts new file mode 100644 index 00000000..05da17e2 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.spec.ts @@ -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; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TemporalProductListComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TemporalProductListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts new file mode 100644 index 00000000..2b7e5017 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts @@ -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(); + productGroupFilter = new BehaviorSubject(''); + data: BehaviorSubject = new BehaviorSubject([]); + dataSource: TemporalProductListDatasource = new TemporalProductListDatasource( + this.searchFilter, + this.productGroupFilter, + this.data, + ); + + form: FormGroup<{ + filter: FormControl; + productGroup: FormControl; + }>; + + 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('', { nonNullable: true }), + productGroup: new FormControl(''), + }); + 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); + } +} diff --git a/overlord/src/app/temporal-product/temporal-product.resolver.spec.ts b/overlord/src/app/temporal-product/temporal-product.resolver.spec.ts new file mode 100644 index 00000000..129c4dd0 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product.resolver.spec.ts @@ -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(); + })); +}); diff --git a/overlord/src/app/temporal-product/temporal-product.resolver.ts b/overlord/src/app/temporal-product/temporal-product.resolver.ts new file mode 100644 index 00000000..7652b7fa --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product.resolver.ts @@ -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 = (route) => { + const id = route.paramMap.get('id'); + return inject(TemporalProductService).get(id as string); +}; diff --git a/overlord/src/app/temporal-product/temporal-product.service.spec.ts b/overlord/src/app/temporal-product/temporal-product.service.spec.ts new file mode 100644 index 00000000..5148aed1 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product.service.spec.ts @@ -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(); + })); +}); diff --git a/overlord/src/app/temporal-product/temporal-product.service.ts b/overlord/src/app/temporal-product/temporal-product.service.ts new file mode 100644 index 00000000..c20808a9 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product.service.ts @@ -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 { + return this.http + .get(`${url}/${id}`) + .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable; + } + + list(): Observable { + return this.http + .get(`${url}/list`) + .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable; + } + + update(product: TemporalProduct): Observable { + return this.http + .put(`${url}/${product.products[0].id}`, product, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable; + } + + delete(id: string): Observable { + return this.http + .delete(`${url}/${id}`, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable; + } +} diff --git a/overlord/src/app/temporal-product/temporal-product.ts b/overlord/src/app/temporal-product/temporal-product.ts new file mode 100644 index 00000000..1a0e1882 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-product.ts @@ -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) { + 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) { + 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) { + this.products = []; + this.skus = []; + Object.assign(this, init); + } +} diff --git a/overlord/src/app/temporal-product/temporal-products.routes.ts b/overlord/src/app/temporal-product/temporal-products.routes.ts new file mode 100644 index 00000000..302bb388 --- /dev/null +++ b/overlord/src/app/temporal-product/temporal-products.routes.ts @@ -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, + }, + }, +];