diff --git a/barker/alembic/versions/367ecf7b898f_bundles.py b/barker/alembic/versions/367ecf7b898f_bundles.py new file mode 100644 index 00000000..58cbe8ff --- /dev/null +++ b/barker/alembic/versions/367ecf7b898f_bundles.py @@ -0,0 +1,103 @@ +"""bundles + +Revision ID: 367ecf7b898f +Revises: 1e0daf6bc1ae +Create Date: 2026-01-28 16:11:21.874409 + +""" + +import sqlalchemy as sa + +from sqlalchemy.dialects import postgresql + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "367ecf7b898f" +down_revision = "1e0daf6bc1ae" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "bundle_items", + sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("bundle_id", sa.Uuid(), nullable=False), + sa.Column("item_id", sa.Uuid(), nullable=False), + sa.Column("quantity", sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column("sale_price", sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column("valid_from", sa.Date(), nullable=True), + sa.Column("valid_till", sa.Date(), nullable=True), + postgresql.ExcludeConstraint( + (sa.column("bundle_id"), "="), + (sa.column("item_id"), "="), + (sa.text("daterange(valid_from, valid_till, '[]')"), "&&"), + using="gist", + name=op.f("uq_bundle_items_bundle_id_item_id"), + ), + sa.ForeignKeyConstraint( + ["bundle_id"], ["stock_keeping_units.id"], name=op.f("fk_bundle_items_bundle_id_stock_keeping_units") + ), + sa.ForeignKeyConstraint( + ["item_id"], ["stock_keeping_units.id"], name=op.f("fk_bundle_items_item_id_stock_keeping_units") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_bundle_items")), + ) + + inventory_type = sa.Enum("regular", "bundle", "bundle_item", name="inventorytype") + # inventory_type.create(op.get_bind()) + op.add_column("inventories", sa.Column("type", inventory_type, server_default=sa.text("'regular'"), nullable=False)) + op.add_column("inventories", sa.Column("parent_id", sa.Uuid(), nullable=True)) + op.drop_constraint(op.f("uq_inventories_kot_id"), "inventories", type_="unique") + op.create_unique_constraint( + op.f("uq_inventories_kot_id"), + "inventories", + ["kot_id", "sku_id", "is_happy_hour", "price", "type", "parent_id"], + postgresql_nulls_not_distinct=True, + ) + op.create_foreign_key( + op.f("fk_inventories_parent_id_inventories"), "inventories", "inventories", ["parent_id"], ["id"] + ) + op.add_column( + "stock_keeping_units", sa.Column("is_bundle", sa.Boolean(), server_default=sa.text("false"), nullable=False) + ) + mc = sa.table( + "menu_categories", + sa.column("id", sa.Uuid()), + sa.column("name", sa.Unicode(length=255)), + sa.column("is_active", sa.Boolean()), + sa.column("is_fixture", sa.Boolean()), + sa.column("sort_order", sa.Integer()), + ) + + op.execute( + mc.insert().values( + id="6752ed9d-6f1a-4941-940a-17759dcc6720", + name="Bundles", + is_active=True, + is_fixture=True, + sort_order=0, + ) + ) + + +def downgrade(): + op.drop_table("bundle_items") + op.drop_column("stock_keeping_units", "is_bundle") + op.drop_constraint(op.f("fk_inventories_parent_id_inventories"), "inventories", type_="foreignkey") + op.drop_constraint(op.f("uq_inventories_kot_id"), "inventories", type_="unique") + op.create_unique_constraint( + op.f("uq_inventories_kot_id"), + "inventories", + ["kot_id", "sku_id", "is_happy_hour", "price"], + postgresql_nulls_not_distinct=False, + ) + op.drop_column("inventories", "parent_id") + op.drop_column("inventories", "type") + mc = sa.table( + "menu_categories", + sa.column("id", sa.Uuid()), + ) + op.execute(mc.delete().where(mc.c.id == "6752ed9d-6f1a-4941-940a-17759dcc6720")) diff --git a/barker/barker/db/base.py b/barker/barker/db/base.py index 2316f3e7..381d4137 100644 --- a/barker/barker/db/base.py +++ b/barker/barker/db/base.py @@ -1,6 +1,7 @@ # Import all the models, so that Base has them before being # imported by Alembic from ..models.bill import Bill +from ..models.bundle_item import BundleItem from ..models.customer import Customer from ..models.customer_discount import CustomerDiscount from ..models.db_setting import DbSetting @@ -9,6 +10,7 @@ from ..models.food_table import FoodTable from ..models.guest_book import GuestBook from ..models.inventory import Inventory from ..models.inventory_modifier import InventoryModifier +from ..models.inventory_type import InventoryType from ..models.kot import Kot from ..models.login_history import LoginHistory from ..models.menu_category import MenuCategory @@ -43,6 +45,7 @@ from .base_class import reg __all__ = [ "Bill", + "BundleItem", "Customer", "CustomerDiscount", "DbSetting", @@ -51,6 +54,7 @@ __all__ = [ "GuestBook", "Inventory", "InventoryModifier", + "InventoryType", "Kot", "LoginHistory", "MenuCategory", diff --git a/barker/barker/main.py b/barker/barker/main.py index fe635742..68e38e1b 100644 --- a/barker/barker/main.py +++ b/barker/barker/main.py @@ -8,6 +8,7 @@ from starlette.middleware.sessions import SessionMiddleware from .core.config import settings from .db.base import reg # noqa: F401 from .routers import ( + bundle, customer, customer_discount, db_settings, @@ -72,6 +73,7 @@ app.include_router(printer.router, prefix="/api/printers", tags=["printers"]) app.include_router(menu_category.router, prefix="/api/menu-categories", tags=["products"]) app.include_router(product.router, prefix="/api/products", tags=["products"]) +app.include_router(bundle.router, prefix="/api/bundles", tags=["products"]) app.include_router(temporal_product.router, prefix="/api/temporal-products", tags=["products"]) app.include_router(device.router, prefix="/api/devices", tags=["devices"]) app.include_router(sale_category.router, prefix="/api/sale-categories", tags=["products"]) diff --git a/barker/barker/models/bundle_item.py b/barker/barker/models/bundle_item.py new file mode 100644 index 00000000..a66cba84 --- /dev/null +++ b/barker/barker/models/bundle_item.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import uuid + +from datetime import date +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import Date, ForeignKey, Numeric, Uuid, func, text +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..db.base_class import reg + + +if TYPE_CHECKING: + from .stock_keeping_unit import StockKeepingUnit + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class BundleItem: + __tablename__ = "bundle_items" + + id: Mapped[uuid.UUID] = mapped_column( + Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") + ) + bundle_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) + item_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) + quantity: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + sale_price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + + valid_from: Mapped[date | None] = mapped_column(Date(), nullable=True) + valid_till: Mapped[date | None] = mapped_column(Date(), nullable=True) + + bundle: Mapped[StockKeepingUnit] = relationship( + "StockKeepingUnit", back_populates="bundle_items", foreign_keys=[bundle_id] + ) + item: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit", foreign_keys=[item_id]) + + __table_args__ = ( + postgresql.ExcludeConstraint( + (bundle_id, "="), + (item_id, "="), + (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + ), + ) + + def __init__( + self, + quantity: Decimal, + sale_price: Decimal, + bundle_id: uuid.UUID, + item_id: uuid.UUID, + bundle: StockKeepingUnit | None = None, + item: StockKeepingUnit | None = None, + valid_from: date | None = None, + valid_till: date | None = None, + id_: uuid.UUID | None = None, + ): + self.quantity = quantity + self.sale_price = sale_price + self.valid_from = valid_from + self.valid_till = valid_till + if bundle_id is not None: + self.bundle_id = bundle_id + if item_id is not None: + self.item_id = item_id + if bundle is not None: + self.bundle = bundle + if item is not None: + self.item = item + if id_ is not None: + self.id = id_ diff --git a/barker/barker/models/inventory.py b/barker/barker/models/inventory.py index d2dea1ae..979ac1b5 100644 --- a/barker/barker/models/inventory.py +++ b/barker/barker/models/inventory.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING from sqlalchemy import ( Boolean, ColumnElement, + Enum, ForeignKey, Integer, Numeric, @@ -21,6 +22,8 @@ from sqlalchemy import ( from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship +from barker.models.inventory_type import InventoryType + from ..db.base_class import reg from .tax import Tax @@ -34,7 +37,7 @@ if TYPE_CHECKING: @reg.mapped_as_dataclass(unsafe_hash=True) class Inventory: __tablename__ = "inventories" - __table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "price"),) + __table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "price", "type", "parent_id"),) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()")) kot_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("kots.id"), nullable=False, index=True) @@ -46,10 +49,16 @@ class Inventory: tax_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("taxes.id"), nullable=False) discount: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) sort_order: Mapped[int] = mapped_column(Integer, nullable=False) + type_: Mapped[InventoryType] = mapped_column( + "type", Enum(InventoryType), server_default=text("regular"), nullable=False + ) + parent_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=True) kot: Mapped[Kot] = relationship(back_populates="inventories") tax: Mapped[Tax] = relationship(back_populates="inventories") sku: Mapped[StockKeepingUnit] = relationship(back_populates="inventories") + parent: Mapped[Inventory | None] = relationship("Inventory", remote_side="Inventory.id", back_populates="children") + children: Mapped[list[Inventory]] = relationship("Inventory", back_populates="parent") modifiers: Mapped[list[InventoryModifier]] = relationship(back_populates="inventory") @@ -64,6 +73,7 @@ class Inventory: tax_id: uuid.UUID | None, tax_rate: Decimal, sort_order: int, + type_: InventoryType = InventoryType.regular, sku: StockKeepingUnit | None = None, tax: Tax | None = None, ): @@ -78,6 +88,7 @@ class Inventory: self.tax_id = tax_id self.tax_rate = tax_rate self.sort_order = sort_order + self.type_ = type_ if sku is not None: self.sku = sku if tax is not None: diff --git a/barker/barker/models/inventory_type.py b/barker/barker/models/inventory_type.py new file mode 100644 index 00000000..a458ee29 --- /dev/null +++ b/barker/barker/models/inventory_type.py @@ -0,0 +1,7 @@ +import enum + + +class InventoryType(str, enum.Enum): + regular = "regular" + bundle = "bundle" + bundle_item = "bundle_item" diff --git a/barker/barker/models/product_version.py b/barker/barker/models/product_version.py index bb4ba19b..dc110348 100644 --- a/barker/barker/models/product_version.py +++ b/barker/barker/models/product_version.py @@ -57,20 +57,27 @@ class ProductVersion: def __init__( self, - product_id: uuid.UUID, + product_id: uuid.UUID | None, name: str = "", fraction_units: str = "", sale_category_id: uuid.UUID | None = None, valid_from: date | None = None, valid_till: date | None = None, + product: Product | None = None, + sale_category: SaleCategory | None = None, id_: uuid.UUID | None = None, ): - self.product_id = product_id + if product_id is not None: + self.product_id = product_id self.name = name self.fraction_units = fraction_units + if product is not None: + self.product = product if sale_category_id is not None: self.sale_category_id = sale_category_id self.valid_from = valid_from self.valid_till = valid_till + if sale_category is not None: + self.sale_category = sale_category if id_ is not None: self.id = id_ diff --git a/barker/barker/models/stock_keeping_unit.py b/barker/barker/models/stock_keeping_unit.py index 94e1372a..f79ea50d 100644 --- a/barker/barker/models/stock_keeping_unit.py +++ b/barker/barker/models/stock_keeping_unit.py @@ -11,6 +11,7 @@ from ..db.base_class import reg if TYPE_CHECKING: + from .bundle_item import BundleItem from .inventory import Inventory from .product import Product from .sku_version import SkuVersion @@ -25,21 +26,27 @@ class StockKeepingUnit: ) product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False) is_not_available: Mapped[bool] = mapped_column(Boolean, nullable=False) + is_bundle: Mapped[bool] = mapped_column(Boolean, nullable=False) sort_order: Mapped[int] = mapped_column(Integer, nullable=False) product: Mapped[Product] = relationship("Product", back_populates="skus") inventories: Mapped[list[Inventory]] = relationship(back_populates="sku") versions: Mapped[list[SkuVersion]] = relationship(back_populates="sku") + bundle_items: Mapped[list[BundleItem]] = relationship( + "BundleItem", back_populates="bundle", foreign_keys="BundleItem.bundle_id" + ) def __init__( self, is_not_available: bool = False, + is_bundle: bool = False, sort_order: int = 0, product_id: uuid.UUID | None = None, product: Product | None = None, id_: uuid.UUID | None = None, ) -> None: self.is_not_available = is_not_available + self.is_bundle = is_bundle self.sort_order = sort_order if product_id is not None: self.product_id = product_id diff --git a/barker/barker/routers/__init__.py b/barker/barker/routers/__init__.py index c8de9746..26803f94 100644 --- a/barker/barker/routers/__init__.py +++ b/barker/barker/routers/__init__.py @@ -1,5 +1,12 @@ from datetime import UTC, date, datetime, timedelta +from sqlalchemy import and_, or_ + +from barker.models.product import Product +from barker.models.product_version import ProductVersion +from barker.models.sku_version import SkuVersion +from barker.models.stock_keeping_unit import StockKeepingUnit + from ..core.config import settings @@ -32,3 +39,39 @@ def dates_overlap(start1: date | None, end1: date | None, start2: date | None, e if end2 is None: end2 = date.max return start1 <= end2 and start2 <= end1 + + +def _pv_active(date_: date): + return and_( + or_(ProductVersion.valid_from == None, ProductVersion.valid_from <= date_), # noqa: E711 + or_(ProductVersion.valid_till == None, ProductVersion.valid_till >= date_), # noqa: E711 + ) + + +def _sv_active(date_: date): + return and_( + or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 + or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 + ) + + +def _pv_onclause(date_: date): + return and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + + +def _sv_onclause(date_: date): + return and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 + or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 + ) diff --git a/barker/barker/routers/bundle.py b/barker/barker/routers/bundle.py new file mode 100644 index 00000000..574e1751 --- /dev/null +++ b/barker/barker/routers/bundle.py @@ -0,0 +1,520 @@ +import uuid + +from collections.abc import Sequence +from datetime import date, timedelta +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException, Security, status +from sqlalchemy import Date, func, or_, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import contains_eager + +from ..core.config import settings +from ..core.security import get_current_active_user as get_user +from ..db.session import SessionFuture +from ..models.bundle_item import BundleItem as BundleItemModel +from ..models.inventory import Inventory +from ..models.kot import Kot +from ..models.product import Product +from ..models.product_version import ProductVersion +from ..models.sku_version import SkuVersion +from ..models.stock_keeping_unit import StockKeepingUnit +from ..models.voucher import Voucher +from ..schemas import bundle as schemas +from ..schemas.menu_category import MenuCategoryLink +from ..schemas.user_token import UserToken +from . import _pv_onclause, _sv_onclause, effective_date + + +router = APIRouter() + +FRACTION_1 = Decimal("1") +YIELD_1 = Decimal("1") +COST_0 = Decimal("0") + + +# ---------- helpers ---------- + + +def bundle_blank() -> schemas.BundleBlank: + return schemas.BundleBlank( + name="", + units="", + sale_price=Decimal("0.00"), + has_happy_hour=False, + is_not_available=False, + sort_order=0, + items=[], + ) + + +def _bundle_info(pv: ProductVersion, sv: SkuVersion, items: Sequence[BundleItemModel]) -> schemas.Bundle: + sku = sv.sku + return schemas.Bundle( + id_=sku.id, + version_id=sv.id, + name=pv.name, + units=pv.fraction_units, + sale_price=sv.sale_price, + has_happy_hour=sv.has_happy_hour, + is_not_available=sku.is_not_available, + sort_order=sku.sort_order, + menu_category=MenuCategoryLink( + id_=sv.menu_category_id, + name=sv.menu_category.name, + skus=[], + ) + if sv.menu_category is not None + else None, + items=[ + schemas.BundleItem( + id_=bi.id, + name=f"{bi.item.product.versions[0].name} ({bi.item.versions[0].units})", + item_id=bi.item_id, + sale_price=bi.sale_price, + quantity=bi.quantity, + ) + for bi in items + ], + ) + + +# ---------- routes ---------- + + +@router.post("", response_model=None) +def save( + data: schemas.BundleIn, + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> None: + try: + with SessionFuture() as db: + if not data.items: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Not enough bundle items.", + ) + sale_price = round(sum(round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), 2) + + sale_category_id = db.execute( + select(ProductVersion.sale_category_id) + .join(Product, onclause=_pv_onclause(date_)) + .join(Product.skus) + .where(StockKeepingUnit.id == data.items[0].item_id) + ).scalar_one() + + product = Product(sort_order=data.sort_order) + db.add(product) + db.flush() + + pv = ProductVersion( + product_id=None, + product=product, + name=data.name, + fraction_units=data.units, # per your rule + sale_category_id=sale_category_id, + sale_category=None, + valid_from=date_, + valid_till=None, + ) + db.add(pv) + + sku = StockKeepingUnit( + product=product, + is_not_available=data.is_not_available, + is_bundle=True, + sort_order=data.sort_order, + ) + db.add(sku) + db.flush() + + sv = SkuVersion( + sku_id=sku.id, + units=data.units, + fraction=FRACTION_1, + product_yield=YIELD_1, + cost_price=COST_0, + sale_price=sale_price, + has_happy_hour=data.has_happy_hour, + menu_category_id=data.menu_category.id_, + valid_from=date_, + valid_till=None, + ) + db.add(sv) + + # bundle items + for it in data.items: + db.add( + BundleItemModel( + bundle_id=sku.id, + item_id=it.item_id, + quantity=round(it.quantity, 5), + sale_price=round(it.sale_price, 2), + valid_from=date_, + valid_till=None, + ) + ) + + db.commit() + + except SQLAlchemyError as e: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.put("/{id_}", response_model=None) +def update_route( + id_: uuid.UUID, # bundle header SKU id + data: schemas.BundleIn, + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> None: + try: + with SessionFuture() as db: + if not data.items: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Not enough bundle items.", + ) + sale_price = round(sum(round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), 2) + # Load header SKU + active header SkuVersion + menu category + product + active ProductVersion + sv: SkuVersion | None = ( + db.execute( + select(SkuVersion) + .join(StockKeepingUnit, onclause=_sv_onclause(date_)) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=_pv_onclause(date_)) + .join(SkuVersion.menu_category) + .where( + StockKeepingUnit.id == id_, + StockKeepingUnit.is_bundle == True, # noqa: E712 + ) + .options( + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + contains_eager(SkuVersion.menu_category), + ) + ) + .unique() + .scalars() + .one_or_none() + ) + + if sv is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") + + sku = sv.sku + product = sku.product + pv = product.versions[0] + pv_changed = pv.name != data.name + + sku.is_not_available = data.is_not_available + sku.sort_order = data.sort_order + product.sort_order = data.sort_order + + if pv_changed: + if pv.valid_from == date_: + pv.name = data.name + else: + pv.valid_till = date_ - timedelta(days=1) + db.add( + ProductVersion( + product_id=product.id, + product=product, + name=data.name, + fraction_units=pv.fraction_units, + sale_category_id=pv.sale_category_id, + valid_from=date_, + valid_till=None, + ) + ) + sv_changed = ( + sv.units != data.units + or Decimal(sv.sale_price).quantize(Decimal("0.01")) != sale_price + or sv.has_happy_hour != data.has_happy_hour + or sv.menu_category_id != data.menu_category.id_ + ) + if sv_changed: + if sv.valid_from == date_: + sv.units = data.units + sv.sale_price = sale_price + sv.has_happy_hour = data.has_happy_hour + sv.menu_category_id = data.menu_category.id_ + + # # enforce bundle constants + # sv.fraction = FRACTION_1 + # sv.product_yield = YIELD_1 + # sv.cost_price = COST_0 + else: + sv.valid_till = date_ - timedelta(days=1) + db.add( + SkuVersion( + sku_id=sku.id, + units=data.units, + fraction=FRACTION_1, + product_yield=YIELD_1, + cost_price=COST_0, + sale_price=sale_price, + has_happy_hour=data.has_happy_hour, + menu_category_id=data.menu_category.id_, + valid_from=date_, + valid_till=None, + ) + ) + + # ---- Bundle items replace/update + existing = db.execute(select(BundleItemModel).where(BundleItemModel.bundle_id == sku.id)).scalars().all() + existing_by_item = {x.item_id: x for x in existing} + incoming_ids = {x.item_id for x in data.items} + + existing_by_item = {x.item_id: x for x in existing} + + incoming_ids = {x.item_id for x in data.items} + + # delete removed + for ex_d in existing: + if ex_d.item_id not in incoming_ids: + if ex_d.valid_from == date_: + db.delete(ex_d) + else: + ex_d.valid_till = date_ - timedelta(days=1) + + # add/update + for it in data.items: + ex = existing_by_item.get(it.item_id) + if ex is None: + db.add( + BundleItemModel( + bundle_id=sku.id, + item_id=it.item_id, + sale_price=round(it.sale_price, 2), + quantity=round(it.quantity, 5), + valid_from=date_, + valid_till=None, + ) + ) + elif ex.valid_from == date_: + ex.sale_price = round(it.sale_price, 2) + ex.quantity = round(it.quantity, 5) + else: + ex.valid_till = date_ - timedelta(days=1) + db.add( + BundleItemModel( + bundle_id=sku.id, + item_id=it.item_id, + sale_price=round(it.sale_price, 2), + quantity=round(it.quantity, 5), + valid_from=date_, + valid_till=None, + ) + ) + + db.commit() + + except SQLAlchemyError as e: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.get("", response_model=schemas.BundleBlank) +def show_blank( + user: UserToken = Security(get_user, scopes=["products"]), +) -> schemas.BundleBlank: + return bundle_blank() + + +@router.get("/list", response_model=list[schemas.Bundle]) +def show_list( + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> list[schemas.Bundle]: + with SessionFuture() as db: + sv_onclause = _sv_onclause(date_) + pv_onclause = _pv_onclause(date_) + + rows = ( + db.execute( + select(SkuVersion) + # .select_from(SkuVersion) + .join(StockKeepingUnit, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .join(SkuVersion.menu_category) + .where(StockKeepingUnit.is_bundle == True) # noqa: E712 + .options( + contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product), + contains_eager(SkuVersion.menu_category), + ) + .order_by(StockKeepingUnit.sort_order, SkuVersion.units) + ) + .unique() + .scalars() + .all() + ) + + out: list[schemas.Bundle] = [] + for sv in rows: + pv = sv.sku.product.versions[0] + items = ( + db.execute( + select(BundleItemModel) + .join(BundleItemModel.item) + .join(SkuVersion, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .where( + BundleItemModel.bundle_id == sv.sku_id, + or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 + or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 + ) + .options( + contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions), + contains_eager(BundleItemModel.item) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + ) + ) + .unique() + .scalars() + .all() + ) + out.append(_bundle_info(pv=pv, sv=sv, items=items)) + return out + + +@router.get("/{id_}", response_model=schemas.Bundle) +def show_id( + id_: uuid.UUID, # bundle header SKU id + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> schemas.Bundle: + with SessionFuture() as db: + sv_onclause = _sv_onclause(date_) + pv_onclause = _pv_onclause(date_) + + sv = ( + db.execute( + select(SkuVersion) + .join(StockKeepingUnit, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .join(SkuVersion.menu_category) + .where( + StockKeepingUnit.id == id_, + StockKeepingUnit.is_bundle == True, # noqa: E712 + ) + .options( + contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product), + contains_eager(SkuVersion.menu_category), + ) + ) + .unique() + .scalar_one_or_none() + ) + + if sv is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") + + pv = sv.sku.product.versions[0] + + items = ( + db.execute( + select(BundleItemModel) + .join(BundleItemModel.item) + .join(SkuVersion, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .where( + BundleItemModel.bundle_id == sv.sku_id, + or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 + or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 + ) + .options( + contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions), + contains_eager(BundleItemModel.item) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + ) + ) + .unique() + .scalars() + .all() + ) + return _bundle_info(pv=pv, sv=sv, items=items) + + +@router.delete("/{id_}", response_model=None) +def delete_route( + id_: uuid.UUID, # bundle header SKU id + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> None: + with SessionFuture() as db: + sv_onclause = _sv_onclause(date_) + pv_onclause = _pv_onclause(date_) + day = func.cast( + Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + ).label("day") + billed = db.execute( + select(func.count(Inventory.id)) + .join(Inventory.kot) + .join(Kot.voucher) + .where(Inventory.sku_id == id_, day >= date_) + ).scalar_one() + if billed > 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="The cannot delete this product as it was billed", + ) + + sv = ( + db.execute( + select(SkuVersion) + .join(StockKeepingUnit, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .where( + StockKeepingUnit.id == id_, + StockKeepingUnit.is_bundle == True, # noqa: E712 + ) + .options(contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product)) + ) + .unique() + .scalar_one_or_none() + ) + + if sv is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") + + pv = sv.sku.product.versions[0] + + # close/delete sku version + if sv.valid_from == date_: + db.delete(sv) + else: + sv.valid_till = date_ - timedelta(days=1) + + # close/delete product version + if pv.valid_from == date_: + db.delete(pv) + else: + pv.valid_till = date_ - timedelta(days=1) + + items = ( + db.execute( + select(BundleItemModel).where( + BundleItemModel.bundle_id == id_, + or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 + or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 + ) + ) + .scalars() + .all() + ) + for bi in items: + if bi.valid_from == date_: + db.delete(bi) + else: + bi.valid_till = date_ - timedelta(days=1) + + db.commit() diff --git a/barker/barker/routers/product.py b/barker/barker/routers/product.py index 1da24290..e458175f 100644 --- a/barker/barker/routers/product.py +++ b/barker/barker/routers/product.py @@ -29,7 +29,7 @@ from ..schemas.product_query import ProductQuery from ..schemas.sale_category import SaleCategoryLink from ..schemas.tax import TaxLink from ..schemas.user_token import UserToken -from . import effective_date +from . import _pv_onclause, _sv_onclause, effective_date router = APIRouter() @@ -168,22 +168,8 @@ def update_route( ) -> None: try: with SessionFuture() as db: - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= date_, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= date_, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 - ) + product_version_onclause = _pv_onclause(date_) + sku_version_onclause = _sv_onclause(date_) version: ProductVersion = db.execute( select(ProductVersion) .join(Product, onclause=product_version_onclause) @@ -446,46 +432,35 @@ def product_list(date_: date, db: Session) -> list[schemas.Product]: # ] -@router.get("/query") +@router.get("/query", response_model=list[ProductQuery]) def show_term( + q: str | None = None, mc: uuid.UUID | None = None, sc: uuid.UUID | None = None, date_: date = Depends(effective_date), current_user: UserToken = Depends(get_user), -): - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= date_, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= date_, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 - ) +) -> list[ProductQuery]: + product_version_onclause = _pv_onclause(date_) + sku_version_onclause = _sv_onclause(date_) print(f"Fetching products for MenuCategory: {mc}, SaleCategory: {sc}, Date: {date_}") list_: list[ProductQuery] = [] query = ( select(SkuVersion) + .join(SkuVersion.menu_category) .join(StockKeepingUnit, onclause=sku_version_onclause) .join(StockKeepingUnit.product) .join(ProductVersion, onclause=product_version_onclause) - # .join(SkuVersion.menu_category) - # .join(ProductVersion.sale_category) + .join(ProductVersion.sale_category) + .join(SaleCategory.tax) .options( - contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product).contains_eager(Product.versions) + contains_eager(SkuVersion.menu_category), + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category) + .contains_eager(SaleCategory.tax), ) ) - if mc is not None: - query = query.join(SkuVersion.menu_category) - if sc is not None: - query = query.join(SkuVersion.menu_category).join(ProductVersion.sale_category).join(SaleCategory.tax) if mc is not None: query = query.where(SkuVersion.menu_category_id == mc).order_by( Product.sort_order, ProductVersion.name, StockKeepingUnit.sort_order, SkuVersion.units @@ -498,36 +473,15 @@ def show_term( StockKeepingUnit.sort_order, SkuVersion.units, ) - - if mc is not None: - query = query.options( - contains_eager(SkuVersion.menu_category), - ) - - if sc is not None: - query = query.options( - contains_eager(ProductVersion.sale_category).contains_eager(SaleCategory.tax), - ) + if q is not None: + for name in q.split(): + query = query.where(or_(ProductVersion.name.ilike(f"%{name}%"), SkuVersion.units.ilike(f"%{name}%"))) with SessionFuture() as db: for item in db.execute(query).unique().scalars().all(): - if sc is not None: - list_.append( - ProductQuery( - id_=item.sku_id, - name=f"{item.sku.product.versions[0].name} ({item.units})", - menu_category=MenuCategoryLink( - id_=item.menu_category_id, name=item.menu_category.name, skus=[] - ), - price=item.sale_price, - has_happy_hour=item.has_happy_hour, - is_not_available=item.sku.is_not_available, - ) - ) - if mc is not None: - list_.append(query_product_info(item, False)) - if item.has_happy_hour: - list_.append(query_product_info(item, True)) + list_.append(query_product_info(item, False)) + if mc is not None and item.has_happy_hour: + list_.append(query_product_info(item, True)) return list_ diff --git a/barker/barker/schemas/bundle.py b/barker/barker/schemas/bundle.py new file mode 100644 index 00000000..6d81e4cc --- /dev/null +++ b/barker/barker/schemas/bundle.py @@ -0,0 +1,55 @@ +import uuid + +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + +from . import Daf, to_camel +from .menu_category import MenuCategoryLink + + +class BundleItem(BaseModel): + id_: uuid.UUID | None = None + name: str = Field(..., min_length=1) + item_id: uuid.UUID = Field(..., description="StockKeepingUnit ID of the item") + sale_price: Daf = Field(ge=Decimal(0)) + quantity: Daf = Field(gt=Decimal(0)) + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + +class BundleIn(BaseModel): + name: str = Field(..., min_length=1) + units: str = Field(..., min_length=1) + sale_price: Daf = Field(ge=Decimal(0)) + has_happy_hour: bool + is_not_available: bool + sort_order: int + + menu_category: MenuCategoryLink = Field(...) + items: list[BundleItem] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + +class Bundle(BundleIn): + id_: uuid.UUID # Sku ID + version_id: uuid.UUID # SkuVersion ID + + items: list[BundleItem] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + +class BundleBlank(BaseModel): + name: str + units: str + sale_price: Daf + has_happy_hour: bool + is_not_available: bool + sort_order: int + + menu_category: MenuCategoryLink | None = None + items: list[BundleItem] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/bookie/src/app/app.routes.ts b/bookie/src/app/app.routes.ts index 1492a443..1e087061 100644 --- a/bookie/src/app/app.routes.ts +++ b/bookie/src/app/app.routes.ts @@ -13,6 +13,10 @@ export const routes: Routes = [ path: 'bill-settlement-report', loadChildren: () => import('./bill-settlement-report/bill-settlement-report.routes').then((mod) => mod.routes), }, + { + path: 'bundles', + loadChildren: () => import('./bundles/bundles.routes').then((mod) => mod.routes), + }, { path: 'cashier-report', loadChildren: () => import('./cashier-report/cashier-report.routes').then((mod) => mod.routes), diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail-datasource.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail-datasource.ts new file mode 100644 index 00000000..a1f78bdc --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-datasource.ts @@ -0,0 +1,16 @@ +import { DataSource } from '@angular/cdk/collections'; +import { Observable } from 'rxjs'; + +import { BundleItem } from '../bundle'; + +export class BundleDetailDatasource extends DataSource { + constructor(private data: Observable) { + super(); + } + + connect(): Observable { + return this.data; + } + + disconnect() {} +} diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.css b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.css new file mode 100644 index 00000000..e69de29b diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.html b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.html new file mode 100644 index 00000000..54943ae4 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.html @@ -0,0 +1,32 @@ +

Edit Bundle Item

+ +
+
+ + Item + + + @for (p of products$ | async; track p) { + {{ p.name }} + } + + + +
+ + Quantity + + + + + Sale Price + + +
+
+
+ +
+ + +
diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.spec.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.spec.ts new file mode 100644 index 00000000..f282843a --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BundleDetailDialogComponent } from './bundle-detail-dialog.component'; + +describe('BundleDetailDialogComponent', () => { + let component: BundleDetailDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BundleDetailDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(BundleDetailDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.ts new file mode 100644 index 00000000..a1855707 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.ts @@ -0,0 +1,110 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { debounceTime, distinctUntilChanged, Observable, of as observableOf, switchMap } from 'rxjs'; + +import { ProductQuery } from '../../core/product-query'; +import { ProductService } from '../../product/product.service'; +import { BundleItem } from '../bundle'; + +@Component({ + selector: 'app-bundle-detail-dialog', + templateUrl: './bundle-detail-dialog.component.html', + styleUrls: ['./bundle-detail-dialog.component.css'], + standalone: true, + imports: [ + AsyncPipe, + MatAutocompleteModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + ], +}) +export class BundleDetailDialogComponent implements OnInit { + private readonly dialogRef = inject>(MatDialogRef); + private readonly productSer = inject(ProductService); + + data = inject<{ item: BundleItem }>(MAT_DIALOG_DATA); + + selectedProduct: ProductQuery | null = null; + products$: Observable; + + form: FormGroup<{ + product: FormControl; + quantity: FormControl; + salePrice: FormControl; + }>; + + constructor() { + this.form = new FormGroup({ + product: new FormControl('', { nonNullable: true }), + quantity: new FormControl(1, { nonNullable: true }), + salePrice: new FormControl(0, { nonNullable: true }), + }); + + this.products$ = this.form.controls.product.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + switchMap((x) => { + if (typeof x !== 'string') return observableOf([]); + return x.trim() === '' ? observableOf([]) : this.productSer.query(x); + }), + ); + + // typing after selecting invalidates prior selection + this.form.controls.product.valueChanges.subscribe((x) => { + if (typeof x === 'string') this.selectedProduct = null; + }); + } + + ngOnInit(): void { + // preload display text using existing name + this.form.setValue({ + product: this.data.item.name ?? '', + quantity: Number(this.data.item.quantity ?? 1), + salePrice: Number(this.data.item.salePrice ?? 0), + }); + + this.selectedProduct = null; + } + + displayFn(product?: ProductQuery | string): string { + return !product ? '' : typeof product === 'string' ? product : product.name; + } + + productSelected(event: MatAutocompleteSelectedEvent): void { + const p = event.option.value as ProductQuery; + this.selectedProduct = p; + } + + accept(): void { + const v = this.form.value; + + const quantity = Number(v.quantity ?? 0); + if (Number.isNaN(quantity) || quantity <= 0) return; + + const salePrice = Number(v.salePrice ?? 0); + if (Number.isNaN(salePrice) || salePrice < 0) return; + + // If a new product was selected, update itemId + name + if (this.selectedProduct?.id) { + this.data.item.itemId = this.selectedProduct.id; + this.data.item.name = this.selectedProduct.name ?? ''; + } else { + // If no selection, don't allow changing SKU silently + // but still allow quantity/price edit + } + + this.data.item.quantity = quantity; + this.data.item.salePrice = salePrice; + + this.dialogRef.close(this.data.item); + } +} diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.css b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.css new file mode 100644 index 00000000..d4041d9d --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.css @@ -0,0 +1,11 @@ +.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/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html new file mode 100644 index 00000000..98fbb188 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html @@ -0,0 +1,120 @@ +

Bundle

+ +
+
+ + Name + + + + + Units + + +
+ +
+ + Sale Price + + Computed from bundle items + + + + Menu Category + + @for (mc of menuCategories; track mc) { + + {{ mc.name }} + + } + + + + Has Happy Hour? + Not Available? +
+ +

Items

+ +
+ + Product + + + @for (p of itemProducts | async; track p) { + {{ p.name }} + } + + + + + Quantity + + + + + Sale Price + + + + +
+
+ +
+ + + + Item + + {{ row.name }} + + + + + + Qty + + {{ row.quantity }} + + + + + + Sale Price + + {{ row.salePrice | currency: 'INR' }} + + + + + + Action + + + + + + + + + +
+ +
+ + @if (!!item.id) { + + } +
diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.spec.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.spec.ts new file mode 100644 index 00000000..1cf201f4 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { BundleDetailComponent } from './bundle-detail.component'; + +describe('BundleDetailComponent', () => { + let component: BundleDetailComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [BundleDetailComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BundleDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts new file mode 100644 index 00000000..4d58a353 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts @@ -0,0 +1,325 @@ +import { AsyncPipe, CurrencyPipe } from '@angular/common'; +import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatOptionModule } from '@angular/material/core'; +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 { of as observableOf, BehaviorSubject, debounceTime, distinctUntilChanged, Observable, switchMap } from 'rxjs'; + +import { MenuCategory } from '../../core/menu-category'; +import { ProductQuery } from '../../core/product-query'; +import { ProductService } from '../../product/product.service'; +import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; +import { Bundle, BundleItem } from '../bundle'; +import { BundleService } from '../bundle.service'; +import { BundleDetailDatasource } from './bundle-detail-datasource'; +import { BundleDetailDialogComponent } from './bundle-detail-dialog.component'; + +@Component({ + selector: 'app-bundle-detail', + templateUrl: './bundle-detail.component.html', + styleUrls: ['./bundle-detail.component.css'], + imports: [ + AsyncPipe, + CurrencyPipe, + MatAutocompleteModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatTableModule, + ReactiveFormsModule, + ], +}) +export class BundleDetailComponent implements OnInit, AfterViewInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + private ser = inject(BundleService); + private productSer = inject(ProductService); + + @ViewChild('name', { static: true }) nameElement?: ElementRef; + + form: FormGroup<{ + // Bundle header fields + name: FormControl; + units: FormControl; + salePrice: FormControl; + menuCategory: FormControl; + hasHappyHour: FormControl; + isNotAvailable: FormControl; + + // Item add row + addRow: FormGroup<{ + itemId: FormControl; + quantity: FormControl; + salePrice: FormControl; + }>; + }>; + + menuCategories: MenuCategory[] = []; + public items$ = new BehaviorSubject([]); + dataSource: BundleDetailDatasource = new BundleDetailDatasource(this.items$); + + item: Bundle = new Bundle(); + + itemProduct: ProductQuery | null = null; + itemProducts: Observable; + + displayedColumns = ['name', 'quantity', 'salePrice', 'action']; + + constructor() { + this.form = new FormGroup({ + name: new FormControl('', { nonNullable: true }), + units: new FormControl('', { nonNullable: true }), + salePrice: new FormControl(0, { nonNullable: true }), + menuCategory: new FormControl('', { nonNullable: true }), + hasHappyHour: new FormControl(false, { nonNullable: true }), + isNotAvailable: new FormControl(false, { nonNullable: true }), + + addRow: new FormGroup({ + itemId: new FormControl('', { nonNullable: true }), + quantity: new FormControl(1, { nonNullable: true }), + salePrice: new FormControl(0, { nonNullable: true }), + }), + }); + this.itemProducts = this.form.controls.addRow.controls.itemId.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + switchMap((x) => { + // if user types, x is string; if user selects, x is Product + if (typeof x !== 'string') { + return observableOf([]); + } + return x.trim() === '' ? observableOf([]) : this.productSer.query(x); + }), + ); + this.form.controls.addRow.controls.itemId.valueChanges.subscribe((x) => { + if (typeof x === 'string') { + this.itemProduct = null; + } + }); + } + + ngOnInit() { + this.route.data.subscribe((value) => { + const data = value as { + item: Bundle; + menuCategories: MenuCategory[]; + }; + this.menuCategories = data.menuCategories; + this.showItem(data.item); + }); + } + + displayFn(product?: ProductQuery | string): string { + return !product ? '' : typeof product === 'string' ? product : product.name; + } + + bundleSelected(event: MatAutocompleteSelectedEvent): void { + const product = event.option.value as ProductQuery; + this.itemProduct = product; + this.form.controls.addRow.controls.salePrice.setValue(product.price ?? 0); + } + + private recalcBundleSalePrice(): void { + const total = (this.item.items ?? []).reduce((sum, x) => { + const qty = Number(x.quantity ?? 0); + const price = Number(x.salePrice ?? 0); + if (Number.isNaN(qty) || Number.isNaN(price)) return sum; + return sum + qty * price; + }, 0); + + // keep model + form in sync + this.item.salePrice = Number(total.toFixed(2)); + + // if the control is disabled, use patchValue + this.form.controls.salePrice.patchValue(this.item.salePrice); + } + + showItem(item: Bundle) { + this.item = item; + + this.form.setValue({ + name: this.item.name ?? '', + units: this.item.units ?? '', + salePrice: Number(this.item.salePrice ?? 0), + menuCategory: this.item.menuCategory?.id ?? '', + hasHappyHour: this.item.hasHappyHour ?? false, + isNotAvailable: this.item.isNotAvailable ?? false, + + addRow: { + itemId: '', + quantity: 1, + salePrice: 0, + }, + }); + this.itemProduct = null; + this.form.controls.salePrice.disable({ emitEvent: false }); + + this.items$.next(this.item.items ?? []); + this.recalcBundleSalePrice(); + } + + ngAfterViewInit() { + setTimeout(() => { + if (this.nameElement !== undefined) { + this.nameElement.nativeElement.focus(); + } + }, 0); + } + + resetAddRow() { + this.form.controls.addRow.reset(); + this.itemProduct = null; + } + + addRow() { + const v = this.form.value.addRow; + if (!v) { + return; + } + + if (!this.itemProduct?.id) { + this.snackBar.open('Please select a product', 'Error'); + return; + } + + const quantity = Number(v.quantity ?? 0); + if (Number.isNaN(quantity) || quantity <= 0) { + this.snackBar.open('Quantity has to be > 0', 'Error'); + return; + } + + const salePrice = Number(v.salePrice ?? 0); + if (Number.isNaN(salePrice) || salePrice < 0) { + this.snackBar.open('Sale Price has to be >= 0', 'Error'); + return; + } + + // name is expected from backend as "name (units)" on GET; + // for new rows we keep it blank (or you can set placeholder) + const bi = new BundleItem({ + itemId: this.itemProduct?.id, + name: this.itemProduct?.name ?? '', + quantity, + salePrice, + }); + + this.item.items.push(bi); + this.items$.next(this.item.items); + this.resetAddRow(); + + this.recalcBundleSalePrice(); + } + + editRow(row: BundleItem) { + const dialogRef = this.dialog.open(BundleDetailDialogComponent, { + width: '650px', + data: { + item: JSON.parse(JSON.stringify(row)) as BundleItem, + }, + }); + + dialogRef.afterClosed().subscribe((result: boolean | BundleItem) => { + if (!result) { + return; + } + Object.assign(row, result as BundleItem); + this.items$.next(this.item.items); + this.resetAddRow(); + this.recalcBundleSalePrice(); + }); + } + + deleteRow(row: BundleItem) { + this.item.items.splice(this.item.items.indexOf(row), 1); + this.items$.next(this.item.items); + this.recalcBundleSalePrice(); + } + + save() { + if (!this.item.items || this.item.items.length === 0) { + this.snackBar.open('Bundle must contain at least one item', 'Error'); + return; + } + this.ser.saveOrUpdate(this.getItem()).subscribe({ + next: () => { + this.snackBar.open('', 'Success'); + this.router.navigateByUrl('/bundles'); + }, + error: (error) => { + this.snackBar.open(error, 'Error'); + }, + }); + } + + delete() { + this.ser.delete(this.item.id as string).subscribe({ + next: () => { + this.snackBar.open('', 'Success'); + this.router.navigateByUrl('/bundles'); + }, + error: (error) => { + this.snackBar.open(error, 'Error'); + }, + }); + } + + confirmDelete(): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '250px', + data: { title: 'Delete Bundle?', content: 'Are you sure? This cannot be undone.' }, + }); + + dialogRef.afterClosed().subscribe((result: boolean) => { + if (result) { + this.delete(); + } + }); + } + + getItem(): Bundle { + const v = this.form.value; + + this.item.name = v.name ?? ''; + this.item.units = v.units ?? ''; + this.item.salePrice = Number(v.salePrice ?? 0); + this.item.hasHappyHour = v.hasHappyHour ?? false; + this.item.isNotAvailable = v.isNotAvailable ?? false; + this.item.sortOrder = this.item.sortOrder ?? 0; + + // menu category + const menuCategoryId = v.menuCategory ?? ''; + if (!menuCategoryId) { + // keep it as-is; backend will 422 anyway, but we can show UI error too + this.snackBar.open('Menu Category is required', 'Error'); + return this.item; + } + + if (this.item.menuCategory === null || this.item.menuCategory === undefined) { + this.item.menuCategory = new MenuCategory(); + } + this.item.menuCategory.id = menuCategoryId; + + // ensure items array exists + if (!this.item.items) { + this.item.items = []; + } + + return this.item; + } +} diff --git a/bookie/src/app/bundles/bundle-list.resolver.spec.ts b/bookie/src/app/bundles/bundle-list.resolver.spec.ts new file mode 100644 index 00000000..ce1ad043 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list.resolver.spec.ts @@ -0,0 +1,15 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { BundleListResolver } from './bundle-list-resolver.service'; + +describe('BundleListResolver', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [BundleListResolver], + }); + }); + + it('should be created', inject([BundleListResolver], (service: BundleListResolver) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/bookie/src/app/bundles/bundle-list.resolver.ts b/bookie/src/app/bundles/bundle-list.resolver.ts new file mode 100644 index 00000000..a79a3992 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list.resolver.ts @@ -0,0 +1,9 @@ +import { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; + +import { Bundle } from './bundle'; +import { BundleService } from './bundle.service'; + +export const bundleListResolver: ResolveFn = () => { + return inject(BundleService).list(); +}; diff --git a/bookie/src/app/bundles/bundle-list/bundle-list-datasource.ts b/bookie/src/app/bundles/bundle-list/bundle-list-datasource.ts new file mode 100644 index 00000000..3cc39af8 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list-datasource.ts @@ -0,0 +1,67 @@ +import { DataSource } from '@angular/cdk/collections'; +import { merge, Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; + +import { Bundle } from '../bundle'; + +export class BundleListDataSource extends DataSource { + public data: Bundle[]; + public filteredData: Bundle[]; + public search: string; + public menuCategory: string; + + constructor( + private readonly searchFilter: Observable, + private readonly menuCategoryFilter: Observable, + private readonly dataObs: Observable, + ) { + super(); + this.data = []; + this.filteredData = []; + this.search = ''; + this.menuCategory = ''; + } + + connect(): Observable { + const dataMutations = [ + this.dataObs.pipe( + tap((x) => { + this.data = x; + }), + ), + this.searchFilter.pipe( + tap((x) => { + this.search = x; + }), + ), + this.menuCategoryFilter.pipe( + tap((x) => { + this.menuCategory = x; + }), + ), + ]; + + return merge(...dataMutations).pipe( + map(() => this.getFilteredData(this.data, this.search, this.menuCategory)), + tap((x) => { + this.filteredData = x; + }), + ); + } + + disconnect() {} + + private getFilteredData(data: Bundle[], search: string, menuCategory: string): Bundle[] { + const tokens = (search ?? '').toLowerCase().split(/\s+/).filter(Boolean); + + return data.filter((b: Bundle) => { + const mcName = b.menuCategory?.name ?? ''; + const hay = `${b.name ?? ''} ${b.units ?? ''} ${mcName}`.toLowerCase(); + + const matchesSearch = tokens.length === 0 || tokens.every((t) => hay.includes(t)); + const matchesMenuCategory = menuCategory === '' || (b.menuCategory?.id ?? '') === menuCategory; + + return matchesSearch && matchesMenuCategory; + }); + } +} diff --git a/bookie/src/app/bundles/bundle-list/bundle-list.component.css b/bookie/src/app/bundles/bundle-list/bundle-list.component.css new file mode 100644 index 00000000..d237630d --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.css @@ -0,0 +1,35 @@ +.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-saleCategory, +.mat-column-info { + margin-left: 4px; + margin-right: 4px; +} + +.mat-column-quantity { + margin-left: 4px; +} + +.items-list { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; +} + +.item-name { + white-space: nowrap; +} diff --git a/bookie/src/app/bundles/bundle-list/bundle-list.component.html b/bookie/src/app/bundles/bundle-list/bundle-list.component.html new file mode 100644 index 00000000..e7fd27ee --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.html @@ -0,0 +1,94 @@ +

+ Bundles + + + add_box + Add + +

+ +
+
+ + Filter + + + + + Menu Category + + -- All Bundles -- + @for (mc of menuCategories; track mc) { + + {{ mc.name }} + + } + + +
+
+ + + + + Name + + {{ row.name }} ({{ row.units }}) + + + + + + Price + + {{ row.salePrice | currency: 'INR' }} + + + + + + Menu Category + + {{ row.menuCategory?.name }} + + + + + + Items + +
+ @for (item of row.items; track item) { +
{{ item.name }} x {{ item.quantity }} @ {{ item.salePrice | currency: 'INR' }}
+ } +
+
+
+ + + + Details + +
+
+ + {{ row.hasHappyHour ? 'sentiment_satisfied_alt' : 'sentiment_dissatisfied' }} + + {{ row.hasHappyHour ? 'Happy Hour' : 'Regular' }} +
+ +
+ + {{ row.isNotAvailable ? 'pause' : 'play_arrow' }} + + {{ row.isNotAvailable ? 'Not Available' : 'Available' }} +
+
+
+
+ + + +
diff --git a/bookie/src/app/bundles/bundle-list/bundle-list.component.spec.ts b/bookie/src/app/bundles/bundle-list/bundle-list.component.spec.ts new file mode 100644 index 00000000..233b1eb5 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; + +import { BundleListComponent } from './bundle-list.component'; + +describe('BundleListComponent', () => { + let component: BundleListComponent; + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [BundleListComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(BundleListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/bookie/src/app/bundles/bundle-list/bundle-list.component.ts b/bookie/src/app/bundles/bundle-list/bundle-list.component.ts new file mode 100644 index 00000000..17cd7c96 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.ts @@ -0,0 +1,117 @@ +import { AsyncPipe, CurrencyPipe } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +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, RouterModule } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +import { MenuCategory } from '../../core/menu-category'; +import { ToCsvService } from '../../shared/to-csv.service'; +import { Bundle } from '../bundle'; +import { BundleListDataSource } from './bundle-list-datasource'; + +@Component({ + selector: 'app-bundle-list', + templateUrl: './bundle-list.component.html', + styleUrls: ['./bundle-list.component.css'], + imports: [ + AsyncPipe, + CurrencyPipe, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatTableModule, + ReactiveFormsModule, + RouterModule, + ], +}) +export class BundleListComponent implements OnInit { + private route = inject(ActivatedRoute); + private toCsv = inject(ToCsvService); + + searchFilter = new BehaviorSubject(''); + menuCategoryFilter = new BehaviorSubject(''); + data: BehaviorSubject = new BehaviorSubject([]); + dataSource: BundleListDataSource = new BundleListDataSource(this.searchFilter, this.menuCategoryFilter, this.data); + + form: FormGroup<{ + menuCategory: FormControl; + filter: FormControl; + }>; + + list: Bundle[] = []; + menuCategories: MenuCategory[] = []; + displayedColumns: string[] = ['name', 'price', 'menuCategory', 'info', 'items']; + + constructor() { + this.form = new FormGroup({ + menuCategory: new FormControl(''), + filter: new FormControl('', { nonNullable: true }), + }); + + this.data.subscribe((data: Bundle[]) => { + this.list = data; + }); + + this.form.controls.filter.valueChanges.pipe(debounceTime(150), distinctUntilChanged()).subscribe((value) => { + this.searchFilter.next(value ?? ''); + }); + } + + filterOn(val: string) { + this.menuCategoryFilter.next(val); + } + + ngOnInit() { + this.dataSource = new BundleListDataSource(this.searchFilter, this.menuCategoryFilter, this.data); + this.route.data.subscribe((value) => { + const data = value as { list: Bundle[]; menuCategories: MenuCategory[] }; + this.loadData(data.list, data.menuCategories); + }); + } + + loadData(list: Bundle[], menuCategories: MenuCategory[]) { + this.menuCategories = menuCategories; + this.data.next(list); + } + + exportCsv() { + const headers = { + Name: 'name', + Units: 'units', + Price: 'salePrice', + MenuCategory: 'menuCategory', + Items: 'items', + }; + + // Map items to count for csv (simple) + const csvList = this.dataSource.filteredData.map((b) => ({ + ...b, + menuCategory: b.menuCategory?.name ?? '', + items: (b.items ?? []) + .map((i) => `${i.name}${i.quantity && i.quantity !== 1 ? ` x${i.quantity}` : ''}`) + .join(' | '), + })); + + const csvData = new Blob([this.toCsv.toCsv(headers, csvList as unknown as Bundle[])], { + type: 'text/csv;charset=utf-8;', + }); + + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(csvData); + link.setAttribute('download', 'bundles.csv'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } +} diff --git a/bookie/src/app/bundles/bundle.resolver.spec.ts b/bookie/src/app/bundles/bundle.resolver.spec.ts new file mode 100644 index 00000000..ff6bd4cf --- /dev/null +++ b/bookie/src/app/bundles/bundle.resolver.spec.ts @@ -0,0 +1,15 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { BundleResolver } from './bundle-resolver.service'; + +describe('BundleResolver', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [BundleResolver], + }); + }); + + it('should be created', inject([BundleResolver], (service: BundleResolver) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/bookie/src/app/bundles/bundle.resolver.ts b/bookie/src/app/bundles/bundle.resolver.ts new file mode 100644 index 00000000..f555809f --- /dev/null +++ b/bookie/src/app/bundles/bundle.resolver.ts @@ -0,0 +1,10 @@ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; + +import { Bundle } from './bundle'; +import { BundleService } from './bundle.service'; + +export const bundleResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const id = route.paramMap.get('id'); + return inject(BundleService).get(id); +}; diff --git a/bookie/src/app/bundles/bundle.service.spec.ts b/bookie/src/app/bundles/bundle.service.spec.ts new file mode 100644 index 00000000..fe374b4b --- /dev/null +++ b/bookie/src/app/bundles/bundle.service.spec.ts @@ -0,0 +1,15 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { BundleService } from './bundle.service'; + +describe('BundleService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [BundleService], + }); + }); + + it('should be created', inject([BundleService], (service: BundleService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/bookie/src/app/bundles/bundle.service.ts b/bookie/src/app/bundles/bundle.service.ts new file mode 100644 index 00000000..79dcae9a --- /dev/null +++ b/bookie/src/app/bundles/bundle.service.ts @@ -0,0 +1,58 @@ +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 { Bundle } from './bundle'; + +const httpOptions = { + headers: new HttpHeaders({ 'Content-Type': 'application/json' }), +}; + +const url = '/api/bundles'; +const serviceName = 'BundleService'; + +@Injectable({ providedIn: 'root' }) +export class BundleService { + private http = inject(HttpClient); + private log = inject(ErrorLoggerService); + + list(): Observable { + return this.http + .get(`${url}/list`) + .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable; + } + + get(id: string | null): Observable { + const getUrl: string = id === null ? `${url}` : `${url}/${id}`; + return this.http + .get(getUrl) + .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable; + } + + save(bundle: Bundle): Observable { + return this.http + .post(`${url}`, bundle, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable; + } + + update(bundle: Bundle): Observable { + return this.http + .put(`${url}/${bundle.id}`, bundle, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable; + } + + saveOrUpdate(bundle: Bundle): Observable { + if (!bundle.versionId) { + return this.save(bundle); + } + return this.update(bundle); + } + + delete(id: string): Observable { + return this.http + .delete(`${url}/${id}`, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable; + } +} diff --git a/bookie/src/app/bundles/bundle.ts b/bookie/src/app/bundles/bundle.ts new file mode 100644 index 00000000..380c531f --- /dev/null +++ b/bookie/src/app/bundles/bundle.ts @@ -0,0 +1,48 @@ +import { MenuCategory } from '../core/menu-category'; + +export class BundleItem { + id?: string; + itemId: string; + name: string; + salePrice: number; + quantity: number; + + public constructor(init?: Partial) { + this.itemId = ''; + this.name = ''; + this.salePrice = 0; + this.quantity = 0; + Object.assign(this, init); + } +} + +export class Bundle { + // backend: id_ (sku id), version_id (skuVersion id) + id?: string; + versionId?: string; + + name: string; + units: string; + salePrice: number; + + hasHappyHour: boolean; + isNotAvailable: boolean; + sortOrder: number; + + menuCategory?: MenuCategory | null; + items: BundleItem[]; + + public constructor(init?: Partial) { + this.name = ''; + this.units = ''; + this.salePrice = 0; + + this.hasHappyHour = false; + this.isNotAvailable = false; + this.sortOrder = 0; + + this.menuCategory = null; + this.items = []; + Object.assign(this, init); + } +} diff --git a/bookie/src/app/bundles/bundles.routes.ts b/bookie/src/app/bundles/bundles.routes.ts new file mode 100644 index 00000000..a20c3eb2 --- /dev/null +++ b/bookie/src/app/bundles/bundles.routes.ts @@ -0,0 +1,41 @@ +import { Routes } from '@angular/router'; + +import { authGuard } from '../auth/auth-guard.service'; +import { menuCategoryListResolver } from '../menu-category/menu-category-list.resolver'; +import { BundleDetailComponent } from './bundle-detail/bundle-detail.component'; +import { bundleListResolver } from './bundle-list.resolver'; +import { BundleListComponent } from './bundle-list/bundle-list.component'; +import { bundleResolver } from './bundle.resolver'; + +export const routes: Routes = [ + { + path: '', + component: BundleListComponent, + canActivate: [authGuard], + data: { permission: 'Products' }, // change if you have a separate permission + resolve: { + list: bundleListResolver, + menuCategories: menuCategoryListResolver, + }, + }, + { + path: 'new', + component: BundleDetailComponent, + canActivate: [authGuard], + data: { permission: 'Products' }, + resolve: { + item: bundleResolver, // returns new Bundle() because id === null (no param here) + menuCategories: menuCategoryListResolver, + }, + }, + { + path: ':id', + component: BundleDetailComponent, + canActivate: [authGuard], + data: { permission: 'Products' }, + resolve: { + item: bundleResolver, + menuCategories: menuCategoryListResolver, + }, + }, +]; diff --git a/bookie/src/app/core/stock-keeping-unit.ts b/bookie/src/app/core/product-query.ts similarity index 58% rename from bookie/src/app/core/stock-keeping-unit.ts rename to bookie/src/app/core/product-query.ts index 09276e03..dd65bd1b 100644 --- a/bookie/src/app/core/stock-keeping-unit.ts +++ b/bookie/src/app/core/product-query.ts @@ -2,39 +2,25 @@ import { MenuCategory } from './menu-category'; import { SaleCategory } from './sale-category'; import { Tax } from './tax'; -export class StockKeepingUnit { +export class ProductQuery { id: string | undefined; - versionId?: string; name: string; - units: string; - menuCategory?: MenuCategory; - saleCategory?: SaleCategory; price: number; hasHappyHour: boolean; isNotAvailable: boolean; - quantity: number; - isActive: boolean; sortOrder: number; + menuCategory?: MenuCategory; + saleCategory?: SaleCategory; - enabled: boolean; tax: Tax; - validFrom: string | null; - validTill: string | null; - - public constructor(init?: Partial) { + public constructor(init?: Partial) { this.id = undefined; this.name = ''; - this.units = ''; this.price = 0; this.hasHappyHour = false; this.isNotAvailable = false; - this.quantity = 0; - this.isActive = true; this.sortOrder = 0; - this.enabled = true; - this.validFrom = null; - this.validTill = null; this.tax = new Tax(); Object.assign(this, init); } diff --git a/bookie/src/app/home/home.component.html b/bookie/src/app/home/home.component.html index 73c49ffc..71a0354f 100644 --- a/bookie/src/app/home/home.component.html +++ b/bookie/src/app/home/home.component.html @@ -76,6 +76,11 @@

Products

} + @if (auth.allowed('products')) { + +

Bundles

+
+ } @if (auth.allowed('temporal-products')) {

Temporal Products

diff --git a/bookie/src/app/product/product-list/product-list.component.ts b/bookie/src/app/product/product-list/product-list.component.ts index 7ba617b4..b46fb57c 100644 --- a/bookie/src/app/product/product-list/product-list.component.ts +++ b/bookie/src/app/product/product-list/product-list.component.ts @@ -10,7 +10,7 @@ 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, RouterLink } from '@angular/router'; +import { ActivatedRoute, RouterModule } from '@angular/router'; import { BehaviorSubject } from 'rxjs'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; @@ -37,7 +37,7 @@ import { ProductListDataSource } from './product-list-datasource'; MatSelectModule, MatTableModule, ReactiveFormsModule, - RouterLink, + RouterModule, ], }) export class ProductListComponent implements OnInit { diff --git a/bookie/src/app/product/product.service.ts b/bookie/src/app/product/product.service.ts index 0e6b8f73..a3aa8e16 100644 --- a/bookie/src/app/product/product.service.ts +++ b/bookie/src/app/product/product.service.ts @@ -5,6 +5,7 @@ import { catchError } from 'rxjs/operators'; import { ErrorLoggerService } from '../core/error-logger.service'; import { Product } from '../core/product'; +import { ProductQuery } from '../core/product-query'; const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json' }), @@ -31,18 +32,25 @@ export class ProductService { .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable; } - listOfSaleCategory(id: string): Observable { + listOfSaleCategory(id: string): Observable { const options = { params: new HttpParams().set('sc', id) }; return this.http - .get(`${url}/query`, options) - .pipe(catchError(this.log.handleError(serviceName, 'listOfSaleCategory'))) as Observable; + .get(`${url}/query`, options) + .pipe(catchError(this.log.handleError(serviceName, 'listOfSaleCategory'))) as Observable; } - listIsActiveOfCategory(id: string): Observable { + listIsActiveOfCategory(id: string): Observable { const options = { params: new HttpParams().set('mc', id) }; return this.http - .get(`${url}/query`, options) - .pipe(catchError(this.log.handleError(serviceName, 'listIsActiveOfCategory'))) as Observable; + .get(`${url}/query`, options) + .pipe(catchError(this.log.handleError(serviceName, 'listIsActiveOfCategory'))) as Observable; + } + + query(query: string): Observable { + const options = { params: new HttpParams().set('q', query) }; + return this.http + .get(`${url}/query`, options) + .pipe(catchError(this.log.handleError(serviceName, 'query'))) as Observable; } save(product: Product): Observable { @@ -75,11 +83,4 @@ export class ProductService { .delete(`${url}/${id}`, httpOptions) .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable; } - - balance(id: string, date: string): Observable { - const options = { params: new HttpParams().set('d', date) }; - return this.http - .get(`${url}/balance`, options) - .pipe(catchError(this.log.handleError(serviceName, 'balance'))) as Observable; - } } diff --git a/bookie/src/app/sale-category/product-list.resolver.ts b/bookie/src/app/sale-category/product-list.resolver.ts index 9ec44178..015cd0db 100644 --- a/bookie/src/app/sale-category/product-list.resolver.ts +++ b/bookie/src/app/sale-category/product-list.resolver.ts @@ -2,10 +2,10 @@ import { inject } from '@angular/core'; import { ResolveFn } from '@angular/router'; import { of as observableOf } from 'rxjs'; -import { Product } from '../core/product'; +import { ProductQuery } from '../core/product-query'; import { ProductService } from '../product/product.service'; -export const productListResolver: ResolveFn = (route) => { +export const productListResolver: ResolveFn = (route) => { const id = route.paramMap.get('id'); if (id === null) { return observableOf([]); diff --git a/bookie/src/app/sale-category/sale-category-detail/sale-category-detail-datasource.ts b/bookie/src/app/sale-category/sale-category-detail/sale-category-detail-datasource.ts index 4a462e9c..217a32d6 100644 --- a/bookie/src/app/sale-category/sale-category-detail/sale-category-detail-datasource.ts +++ b/bookie/src/app/sale-category/sale-category-detail/sale-category-detail-datasource.ts @@ -2,12 +2,12 @@ import { DataSource } from '@angular/cdk/collections'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; -import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit'; +import { ProductQuery } from '../../core/product-query'; -export class SaleCategoryDetailDatasource extends DataSource { - private data: Product[] = []; +export class SaleCategoryDetailDatasource extends DataSource { + private data: ProductQuery[] = []; - constructor(private readonly dataObs: Observable) { + constructor(private readonly dataObs: Observable) { super(); this.dataObs = dataObs.pipe( tap((x) => { @@ -16,7 +16,7 @@ export class SaleCategoryDetailDatasource extends DataSource { ); } - connect(): Observable { + connect(): Observable { return this.dataObs; } diff --git a/bookie/src/app/sale-category/sale-category-detail/sale-category-detail.component.ts b/bookie/src/app/sale-category/sale-category-detail/sale-category-detail.component.ts index 25c92eff..5311933f 100644 --- a/bookie/src/app/sale-category/sale-category-detail/sale-category-detail.component.ts +++ b/bookie/src/app/sale-category/sale-category-detail/sale-category-detail.component.ts @@ -14,8 +14,8 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { round } from 'mathjs'; import { BehaviorSubject } from 'rxjs'; +import { ProductQuery } from '../../core/product-query'; import { SaleCategory } from '../../core/sale-category'; -import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit'; import { Tax } from '../../core/tax'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { SaleCategoryService } from '../sale-category.service'; @@ -55,7 +55,7 @@ export class SaleCategoryDetailComponent implements OnInit, AfterViewInit { taxes: Tax[] = []; item: SaleCategory = new SaleCategory(); - products: BehaviorSubject = new BehaviorSubject([]); + products: BehaviorSubject = new BehaviorSubject([]); dataSource: SaleCategoryDetailDatasource = new SaleCategoryDetailDatasource(this.products); displayedColumns = ['name', 'price', 'menuCategory']; @@ -70,7 +70,7 @@ export class SaleCategoryDetailComponent implements OnInit, AfterViewInit { ngOnInit() { this.route.data.subscribe((value) => { - const data = value as { item: SaleCategory; taxes: Tax[]; products: Product[] }; + const data = value as { item: SaleCategory; taxes: Tax[]; products: ProductQuery[] }; this.showItem(data.item); this.taxes = data.taxes; this.products.next(data.products); diff --git a/bookie/src/app/sales/bill.service.ts b/bookie/src/app/sales/bill.service.ts index f8d597d2..9c728a53 100644 --- a/bookie/src/app/sales/bill.service.ts +++ b/bookie/src/app/sales/bill.service.ts @@ -9,13 +9,13 @@ import { BillViewItem } from '../core/bill-view-item'; import { ModifierCategory } from '../core/modifier-category'; import { ReceivePaymentItem } from '../core/receive-payment-item'; import { SaleCategory } from '../core/sale-category'; -import { StockKeepingUnit } from '../core/stock-keeping-unit'; import { Table } from '../core/table'; import { ModifierCategoryService } from '../modifier-categories/modifier-category.service'; import { MathService } from '../shared/math.service'; import { Bill } from './bills/bill'; import { Inventory } from './bills/inventory'; import { Kot } from './bills/kot'; +import { ProductLink } from './bills/product-link'; import { VoucherType } from './bills/voucher-type'; import { VoucherService } from './bills/voucher.service'; import { ModifiersComponent } from './modifiers/modifiers.component'; @@ -109,7 +109,7 @@ export class BillService { ); } - addSku(sku: StockKeepingUnit, quantity: number, discount: number): void { + addSku(sku: ProductLink, quantity: number, discount: number): void { const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; const old = newKot.inventories.find((x) => x.sku.id === sku.id && x.isHappyHour === sku.hasHappyHour); if (quantity < 0) { @@ -127,7 +127,7 @@ export class BillService { quantity, price: sku.price, isHappyHour: sku.hasHappyHour, - taxRate: sku.tax.rate, + taxRate: sku.tax?.rate, tax: sku.tax, discount, modifiers: [], diff --git a/bookie/src/app/sales/bills/inventory.ts b/bookie/src/app/sales/bills/inventory.ts index af8eab8e..e5d9cc23 100644 --- a/bookie/src/app/sales/bills/inventory.ts +++ b/bookie/src/app/sales/bills/inventory.ts @@ -1,10 +1,10 @@ import { Modifier } from '../../core/modifier'; -import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit'; import { Tax } from '../../core/tax'; +import { ProductLink } from './product-link'; export class Inventory { id: string | undefined; - sku: Product; + sku: ProductLink; quantity: number; price: number; isHappyHour: boolean; @@ -16,7 +16,7 @@ export class Inventory { public constructor(init?: Partial) { this.id = undefined; - this.sku = new Product(); + this.sku = new ProductLink(); this.quantity = 0; this.price = 0; this.isHappyHour = false; diff --git a/bookie/src/app/sales/bills/product-link.ts b/bookie/src/app/sales/bills/product-link.ts new file mode 100644 index 00000000..3605a458 --- /dev/null +++ b/bookie/src/app/sales/bills/product-link.ts @@ -0,0 +1,21 @@ +import { SaleCategory } from '../../core/sale-category'; +import { Tax } from '../../core/tax'; + +export class ProductLink { + id: string | undefined; + name: string; + price: number; + tax: Tax | undefined; + saleCategory: SaleCategory | undefined; + hasHappyHour: boolean; + enabled: boolean; + + public constructor(init?: Partial) { + this.id = undefined; + this.name = ''; + this.price = 0; + this.hasHappyHour = false; + this.enabled = false; + Object.assign(this, init); + } +} diff --git a/bookie/src/app/sales/products/products.component.ts b/bookie/src/app/sales/products/products.component.ts index dd28522d..4b9838a3 100644 --- a/bookie/src/app/sales/products/products.component.ts +++ b/bookie/src/app/sales/products/products.component.ts @@ -4,8 +4,9 @@ import { MatCardModule } from '@angular/material/card'; import { MatRippleModule } from '@angular/material/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ProductQuery } from '../../core/product-query'; import { BillService } from '../bill.service'; -import { Product } from '../product'; +import { ProductLink } from '../bills/product-link'; @Component({ selector: 'app-products', @@ -17,19 +18,19 @@ export class ProductsComponent implements OnInit { private route = inject(ActivatedRoute); private bs = inject(BillService); - list: Product[] = []; + list: ProductQuery[] = []; ngOnInit() { this.route.data.subscribe((value) => { - const data = value as { list: Product[] }; + const data = value as { list: ProductQuery[] }; this.list = data.list; }); } - addSku(product: Product): void { + addSku(product: ProductQuery): void { if (product.isNotAvailable) { return; } - this.bs.addSku(product, 1, 0); + this.bs.addSku(new ProductLink(product), 1, 0); } } diff --git a/bookie/src/app/sales/products/products.resolver.ts b/bookie/src/app/sales/products/products.resolver.ts index 213338ea..91e941bf 100644 --- a/bookie/src/app/sales/products/products.resolver.ts +++ b/bookie/src/app/sales/products/products.resolver.ts @@ -1,10 +1,10 @@ import { inject } from '@angular/core'; import { ResolveFn } from '@angular/router'; -import { Product } from '../../core/product'; +import { ProductQuery } from '../../core/product-query'; import { ProductService } from '../../product/product.service'; -export const productsResolver: ResolveFn = (route) => { +export const productsResolver: ResolveFn = (route) => { const id = route.paramMap.get('id') as string; return inject(ProductService).listIsActiveOfCategory(id); }; diff --git a/bookie/src/app/update-product-prices/update-product-prices.service.ts b/bookie/src/app/update-product-prices/update-product-prices.service.ts index f841c1de..b4d86521 100644 --- a/bookie/src/app/update-product-prices/update-product-prices.service.ts +++ b/bookie/src/app/update-product-prices/update-product-prices.service.ts @@ -4,7 +4,6 @@ import { Observable } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { ErrorLoggerService } from '../core/error-logger.service'; -import { StockKeepingUnit as Product } from '../core/stock-keeping-unit'; import { UpdateProductPrices } from './update-product-prices'; const url = '/api/update-product-prices'; @@ -31,7 +30,7 @@ export class UpdateProductPricesService { save(item: UpdateProductPrices): Observable { const saveUrl: string = item.menuCategoryId === null ? url : `${url}/${item.menuCategoryId}`; return this.http - .post(saveUrl, item) + .post(saveUrl, item) .pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable; } }