From 6f433ef203f803c38962f90b0b07cdd17a9b15be Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Fri, 31 May 2024 07:30:02 +0530 Subject: [PATCH] Feature: Voucher tags. This will eventually help in filtering ledger based on tags and also to group vouchers. --- .../versions/c460289ba94d_voucher_tags.py | 39 +++++ brewman/brewman/core/security.py | 6 +- brewman/brewman/db/base.py | 1 + brewman/brewman/main.py | 2 + brewman/brewman/models/recipe.py | 4 +- brewman/brewman/models/tag.py | 9 +- brewman/brewman/models/voucher.py | 9 +- brewman/brewman/models/voucher_tag.py | 23 +++ brewman/brewman/routers/journal.py | 4 + brewman/brewman/routers/purchase.py | 4 + brewman/brewman/routers/purchase_return.py | 4 + brewman/brewman/routers/tag.py | 155 ++++++++++++++++++ brewman/brewman/routers/voucher.py | 3 + brewman/brewman/schemas/tag.py | 11 ++ brewman/brewman/schemas/voucher.py | 2 + brewman/pyproject.toml | 2 +- overlord/package.json | 2 +- .../app/core/nav-bar/nav-bar.component.html | 3 +- overlord/src/app/core/tag.service.spec.ts | 17 ++ overlord/src/app/core/tag.service.ts | 64 ++++++++ overlord/src/app/core/tag.ts | 10 ++ overlord/src/app/core/voucher.ts | 3 + .../src/app/journal/journal.component.html | 27 +++ overlord/src/app/journal/journal.component.ts | 53 +++++- overlord/src/app/journal/journal.module.ts | 2 + .../src/app/payment/payment.component.html | 27 +++ overlord/src/app/payment/payment.component.ts | 53 +++++- overlord/src/app/payment/payment.module.ts | 2 + .../product-detail.component.html | 4 - .../purchase-return.component.html | 27 +++ .../purchase-return.component.ts | 53 +++++- .../purchase-return/purchase-return.module.ts | 2 + .../src/app/purchase/purchase.component.html | 27 +++ .../src/app/purchase/purchase.component.ts | 53 +++++- overlord/src/app/purchase/purchase.module.ts | 2 + .../src/app/receipt/receipt.component.html | 27 +++ overlord/src/app/receipt/receipt.component.ts | 53 +++++- overlord/src/app/receipt/receipt.module.ts | 2 + 38 files changed, 768 insertions(+), 23 deletions(-) create mode 100644 brewman/alembic/versions/c460289ba94d_voucher_tags.py create mode 100644 brewman/brewman/models/voucher_tag.py create mode 100644 brewman/brewman/routers/tag.py create mode 100644 brewman/brewman/schemas/tag.py create mode 100644 overlord/src/app/core/tag.service.spec.ts create mode 100644 overlord/src/app/core/tag.service.ts create mode 100644 overlord/src/app/core/tag.ts diff --git a/brewman/alembic/versions/c460289ba94d_voucher_tags.py b/brewman/alembic/versions/c460289ba94d_voucher_tags.py new file mode 100644 index 00000000..20898e91 --- /dev/null +++ b/brewman/alembic/versions/c460289ba94d_voucher_tags.py @@ -0,0 +1,39 @@ +"""voucher tags + +Revision ID: c460289ba94d +Revises: 66abfc21db73 +Create Date: 2024-05-30 20:48:35.241777 + +""" + +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "c460289ba94d" +down_revision = "66abfc21db73" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "voucher_tags", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("voucher_id", sa.Uuid(), nullable=False), + sa.Column("tag_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], name=op.f("fk_voucher_tags_tag_id_tags")), + sa.ForeignKeyConstraint(["voucher_id"], ["vouchers.id"], name=op.f("fk_voucher_tags_voucher_id_vouchers")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_voucher_tags")), + sa.UniqueConstraint("voucher_id", "tag_id", name=op.f("uq_voucher_tags_voucher_id")), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("voucher_tags") + # ### end Alembic commands ### diff --git a/brewman/brewman/core/security.py b/brewman/brewman/core/security.py index 491b89df..6a999aaa 100644 --- a/brewman/brewman/core/security.py +++ b/brewman/brewman/core/security.py @@ -35,11 +35,7 @@ class TokenData(BaseModel): def create_access_token(*, data: dict[str, Any], expires_delta: timedelta | None = None) -> str: to_encode = data.copy() - expire = ( - datetime.now(UTC).replace(tzinfo=None) + expires_delta - if expires_delta - else datetime.now(UTC).replace(tzinfo=None) + timedelta(minutes=15) - ) + expire = datetime.now(UTC).replace(tzinfo=None) + (expires_delta or timedelta(minutes=15)) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) return encoded_jwt diff --git a/brewman/brewman/db/base.py b/brewman/brewman/db/base.py index ad82c9fd..79020d24 100644 --- a/brewman/brewman/db/base.py +++ b/brewman/brewman/db/base.py @@ -35,5 +35,6 @@ from ..models.tag import Tag # noqa: F401 from ..models.user import User # noqa: F401 from ..models.user_role import UserRole # noqa: F401 from ..models.voucher import Voucher # noqa: F401 +from ..models.voucher_tag import VoucherTag # noqa: F401 from ..models.voucher_type import VoucherType # noqa: F401 from .base_class import reg # noqa: F401 diff --git a/brewman/brewman/main.py b/brewman/brewman/main.py index 87340f6d..99f36cd5 100644 --- a/brewman/brewman/main.py +++ b/brewman/brewman/main.py @@ -40,6 +40,7 @@ from .routers import ( recipe, recipe_template, role, + tag, title, user, voucher, @@ -83,6 +84,7 @@ app.include_router(fingerprint_report.router, prefix="/fingerprint-report", tags app.include_router(rate_contract.router, prefix="/api/rate-contracts", tags=["products"]) app.include_router(cost_centre.router, prefix="/api/cost-centres", tags=["cost-centres"]) +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"]) diff --git a/brewman/brewman/models/recipe.py b/brewman/brewman/models/recipe.py index abb851de..742801ae 100644 --- a/brewman/brewman/models/recipe.py +++ b/brewman/brewman/models/recipe.py @@ -39,9 +39,9 @@ class Recipe: sku: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit", back_populates="recipes") tags: Mapped[list[Tag]] = relationship( "Tag", - secondary=RecipeTag.__table__, + secondary=RecipeTag.__table__, # type: ignore[attr-defined] order_by="Tag.name", - back_populates="recipes", # type: ignore[attr-defined] + back_populates="recipes", ) def __init__( diff --git a/brewman/brewman/models/tag.py b/brewman/brewman/models/tag.py index d980ce7d..e6a8d441 100644 --- a/brewman/brewman/models/tag.py +++ b/brewman/brewman/models/tag.py @@ -22,7 +22,12 @@ class Tag: recipes: Mapped[list["Recipe"]] = relationship( "Recipe", - secondary=RecipeTag.__table__, + secondary=RecipeTag.__table__, # type: ignore[attr-defined] order_by="Recipe.date_", - back_populates="tags", # type: ignore[attr-defined] + back_populates="tags", ) + + def __init__(self, name: str, id_: uuid.UUID | None = None): + self.name = name + if id_ is not None: + self.id = id_ diff --git a/brewman/brewman/models/voucher.py b/brewman/brewman/models/voucher.py index 73a886a6..b6015fdd 100644 --- a/brewman/brewman/models/voucher.py +++ b/brewman/brewman/models/voucher.py @@ -7,6 +7,7 @@ from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Unicode, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db.base_class import reg +from .voucher_tag import VoucherTag from .voucher_type import VoucherType @@ -15,6 +16,7 @@ if TYPE_CHECKING: from .incentive import Incentive from .inventory import Inventory from .journal import Journal + from .tag import Tag from .user import User @@ -41,7 +43,6 @@ class Voucher: journals: Mapped[list["Journal"]] = relationship( "Journal", cascade="delete, delete-orphan", back_populates="voucher" ) - inventories: Mapped[list["Inventory"]] = relationship( "Inventory", cascade="delete, delete-orphan", back_populates="voucher" ) @@ -52,6 +53,12 @@ class Voucher: "Incentive", cascade="delete, delete-orphan", backref="voucher" ) + tags: Mapped[list["Tag"]] = relationship( + "Tag", + secondary=VoucherTag.__table__, # type: ignore[attr-defined] + order_by="Tag.name", + ) + def __init__( self, date_: date, diff --git a/brewman/brewman/models/voucher_tag.py b/brewman/brewman/models/voucher_tag.py new file mode 100644 index 00000000..f4100e2c --- /dev/null +++ b/brewman/brewman/models/voucher_tag.py @@ -0,0 +1,23 @@ +import uuid + +from sqlalchemy import UniqueConstraint, Uuid +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.schema import ForeignKey + +from ..db.base_class import reg + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class VoucherTag: + __tablename__ = "voucher_tags" + __table_args__ = (UniqueConstraint("voucher_id", "tag_id"),) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, insert_default=uuid.uuid4) + voucher_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("vouchers.id")) + tag_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("tags.id")) + + def __init__(self, voucher_id: uuid.UUID, tag_id: uuid.UUID, id_: uuid.UUID | None = None): + self.voucher_id = voucher_id + self.tag_id = tag_id + if id_ is not None: + self.id = id_ diff --git a/brewman/brewman/routers/journal.py b/brewman/brewman/routers/journal.py index f69706d3..b7c82d76 100644 --- a/brewman/brewman/routers/journal.py +++ b/brewman/brewman/routers/journal.py @@ -10,6 +10,8 @@ from sqlalchemy.orm import Session import brewman.schemas.input as schema_in import brewman.schemas.voucher as output +from brewman.routers.tag import save_tags, update_tags + from ..core.security import get_current_active_user as get_user from ..core.session import get_date, set_date from ..db.session import SessionFuture @@ -43,6 +45,7 @@ def save_route( db.flush() check_journals_are_valid(item) save_files(item.id, i, t, db) + save_tags(item, data.tags, db) db.commit() set_date(data.date_.strftime("%d-%b-%Y"), request.session) info = voucher_info(item, db) @@ -103,6 +106,7 @@ def update_route( item: Voucher = update_voucher(id_, data, user, db) check_journals_are_valid(item) update_files(item.id, data.files, i, t, db) + update_tags(item, data.tags, db) db.commit() set_date(data.date_.strftime("%d-%b-%Y"), request.session) return voucher_info(item, db) diff --git a/brewman/brewman/routers/purchase.py b/brewman/brewman/routers/purchase.py index 633d4e64..c3d9951c 100644 --- a/brewman/brewman/routers/purchase.py +++ b/brewman/brewman/routers/purchase.py @@ -11,6 +11,8 @@ from sqlalchemy.orm import Session import brewman.schemas.input as schema_in import brewman.schemas.voucher as output +from brewman.routers.tag import save_tags, update_tags + from ..core.security import get_current_active_user as get_user from ..core.session import get_date, set_date from ..db.session import SessionFuture @@ -58,6 +60,7 @@ def save_route( save_journals(item, data.vendor, db) check_journals_are_valid(item) save_files(item.id, i, t, db) + save_tags(item, data.tags, db) db.commit() set_date(data.date_.strftime("%d-%b-%Y"), request.session) info = voucher_info(item, db) @@ -189,6 +192,7 @@ def update_route( update_journals(item, data.vendor, db) check_journals_are_valid(item) update_files(item.id, data.files, i, t, db) + update_tags(item, data.tags, db) db.commit() set_date(data.date_.strftime("%d-%b-%Y"), request.session) return voucher_info(item, db) diff --git a/brewman/brewman/routers/purchase_return.py b/brewman/brewman/routers/purchase_return.py index 308bf61b..4cdfbb6d 100644 --- a/brewman/brewman/routers/purchase_return.py +++ b/brewman/brewman/routers/purchase_return.py @@ -11,6 +11,8 @@ from sqlalchemy.orm import Session import brewman.schemas.input as schema_in import brewman.schemas.voucher as output +from brewman.routers.tag import save_tags, update_tags + from ..core.security import get_current_active_user as get_user from ..core.session import get_date, set_date from ..db.session import SessionFuture @@ -55,6 +57,7 @@ def save_route( save_journals(item, data.vendor, db) check_journals_are_valid(item) save_files(item.id, i, t, db) + save_tags(item, data.tags, db) db.commit() set_date(data.date_.strftime("%d-%b-%Y"), request.session) info = voucher_info(item, db) @@ -179,6 +182,7 @@ def update_route( update_journals(item, data.vendor, db) check_journals_are_valid(item) update_files(item.id, data.files, i, t, db) + update_tags(item, data.tags, db) db.commit() set_date(data.date_.strftime("%d-%b-%Y"), request.session) return voucher_info(item, db) diff --git a/brewman/brewman/routers/tag.py b/brewman/brewman/routers/tag.py new file mode 100644 index 00000000..76f8475c --- /dev/null +++ b/brewman/brewman/routers/tag.py @@ -0,0 +1,155 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Security, status +from sqlalchemy import delete, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +import brewman.schemas.tag as schemas + +from brewman.models.recipe_tag import RecipeTag +from brewman.models.voucher import Voucher +from brewman.models.voucher_tag import VoucherTag + +from ..core.security import get_current_active_user as get_user +from ..db.session import SessionFuture +from ..models.tag import Tag +from ..schemas.user import UserToken + + +router = APIRouter() + + +@router.post("", response_model=schemas.Tag) +def save( + data: schemas.Tag, + user: UserToken = Security(get_user, scopes=["tags"]), +) -> schemas.Tag: + try: + with SessionFuture() as db: + if len(data.name) == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tag name cannot be empty", + ) + item = Tag(name=data.name) + db.add(item) + db.commit() + return tag_info(item) + except SQLAlchemyError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) + + +@router.put("/{id_}", response_model=schemas.Tag) +def update_route( + id_: uuid.UUID, + data: schemas.Tag, + user: UserToken = Security(get_user, scopes=["tags"]), +) -> schemas.Tag: + try: + with SessionFuture() as db: + item = db.execute(select(Tag).where(Tag.id == id_)).scalar_one() + if len(data.name) == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tag name cannot be empty", + ) + item.name = data.name + db.commit() + return tag_info(item) + except SQLAlchemyError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) + + +@router.delete("/{id_}", response_model=None) +def delete_route( + id_: uuid.UUID, + user: UserToken = Security(get_user, scopes=["tags"]), +) -> None: + with SessionFuture() as db: + item: Tag = db.execute(select(Tag).where(Tag.id == id_)).scalar_one() + db.execute(delete(VoucherTag).where(VoucherTag.tag_id == item.id)) + db.execute(delete(RecipeTag).where(RecipeTag.tag_id == item.id)) + db.execute(delete(Tag).where(Tag.id == item.id)) + db.commit() + + +@router.get("", response_model=schemas.Tag) +def show_blank( + user: UserToken = Security(get_user, scopes=["tags"]), +) -> schemas.Tag: + return tag_blank() + + +@router.get("/list", response_model=list[schemas.Tag]) +async def show_list(user: UserToken = Depends(get_user)) -> list[schemas.Tag]: + with SessionFuture() as db: + return [tag_info(item) for item in db.execute(select(Tag).order_by(Tag.name)).scalars().all()] + + +@router.get("/query", response_model=list[schemas.Tag]) +async def show_term( + q: str, + current_user: UserToken = Depends(get_user), +) -> list[schemas.Tag]: + with SessionFuture() as db: + query_ = select(Tag) + for name in q.split(): + query_ = query_.where(Tag.name.ilike(f"%{name}%")) + query_ = query_.order_by(Tag.name) + data: list[Tag] = list(db.execute(query_).scalars().all()) + + return [tag_info(item) for item in data] + + +@router.get("/{id_}", response_model=schemas.Tag) +def show_id( + id_: uuid.UUID, + user: UserToken = Security(get_user, scopes=["tags"]), +) -> schemas.Tag: + with SessionFuture() as db: + item = db.execute(select(Tag).where(Tag.id == id_)).scalar_one() + return tag_info(item) + + +def tag_info(item: Tag) -> schemas.Tag: + return schemas.Tag( + id_=item.id, + name=item.name, + ) + + +def tag_blank() -> schemas.Tag: + return schemas.Tag( + id_=None, + name="", + ) + + +def save_tags(voucher: Voucher, tags: list[schemas.Tag], db: Session) -> None: + for tag in tags: + if tag.id_ is None: + item = Tag(tag.name) + db.add(item) + db.flush() + else: + item = db.execute(select(Tag).where(Tag.id == tag.id_)).scalar_one() + voucher.tags.append(item) + + +def update_tags(voucher: Voucher, tags: list[schemas.Tag], db: Session) -> None: + old = [f.id_ for f in tags if f.id_] + removed = ( + db.execute(select(VoucherTag).where(VoucherTag.voucher_id == voucher.id, ~VoucherTag.tag_id.in_(old))) + .scalars() + .all() + ) + for item in removed: + db.delete(item) + save_tags(voucher, tags, db) diff --git a/brewman/brewman/routers/voucher.py b/brewman/brewman/routers/voucher.py index 38af2bab..9c69f9c8 100644 --- a/brewman/brewman/routers/voucher.py +++ b/brewman/brewman/routers/voucher.py @@ -162,6 +162,7 @@ def delete_voucher( item.batch.quantity_remaining += item.quantity for b in batches_to_delete: db.delete(b) + voucher.tags.clear() db.delete(voucher) for image in images: db.delete(image) @@ -196,6 +197,7 @@ def voucher_info(voucher: Voucher, db: Session) -> output.Voucher: last_edit_date=voucher.last_edit_date, user=output.UserLink(id_=voucher.user.id, name=voucher.user.name), poster=voucher.poster.name if voucher.posted else "", + tags=[output.Tag(id_=t.id, name=t.name) for t in voucher.tags], ) if voucher.reconcile_date is not None: json_voucher.reconcile_date = voucher.reconcile_date @@ -311,6 +313,7 @@ def blank_voucher(info: BlankVoucherInfo, db: Session) -> output.Voucher: incentives=[], employee_benefits=[], files=[], + tags=[], ) if info.type_ == VoucherType.JOURNAL: pass diff --git a/brewman/brewman/schemas/tag.py b/brewman/brewman/schemas/tag.py new file mode 100644 index 00000000..9b9acbb2 --- /dev/null +++ b/brewman/brewman/schemas/tag.py @@ -0,0 +1,11 @@ +import uuid + +from pydantic import BaseModel, ConfigDict + +from brewman.schemas import to_camel + + +class Tag(BaseModel): + id_: uuid.UUID | None + name: str + model_config = ConfigDict(alias_generator=to_camel, str_strip_whitespace=True, populate_by_name=True) diff --git a/brewman/brewman/schemas/voucher.py b/brewman/brewman/schemas/voucher.py index 084516c6..89dcc74d 100644 --- a/brewman/brewman/schemas/voucher.py +++ b/brewman/brewman/schemas/voucher.py @@ -23,6 +23,7 @@ from .incentive import Incentive from .inventory import Inventory from .journal import Journal from .product import ProductLink # noqa: F401 +from .tag import Tag from .user_link import UserLink @@ -32,6 +33,7 @@ class VoucherIn(BaseModel): is_starred: bool type_: str files: list[ImageUpload] + tags: list[Tag] @field_validator("date_", mode="before") @classmethod diff --git a/brewman/pyproject.toml b/brewman/pyproject.toml index 3ed8b842..daccb673 100644 --- a/brewman/pyproject.toml +++ b/brewman/pyproject.toml @@ -5,7 +5,7 @@ description = "Accounting plus inventory management for a restaurant." authors = ["tanshu "] [tool.poetry.dependencies] -python = "^3.11" +python = "^3.12" uvicorn = {extras = ["standard"], version = "^0.23.2"} fastapi = {extras = ["all"], version = "^0.110.1"} python-jose = {extras = ["cryptography"], version = "^3.3.0"} diff --git a/overlord/package.json b/overlord/package.json index fae141f7..e546ac67 100644 --- a/overlord/package.json +++ b/overlord/package.json @@ -53,7 +53,7 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", - "eslint-plugin-unused-imports": "^3.1.0", + "eslint-plugin-unused-imports": "^3.2.0", "husky": "^9.0.11", "jasmine-core": "~5.1.0", "jasmine-spec-reporter": "7.0.0", 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 1b794a5f..cda7d2fa 100644 --- a/overlord/src/app/core/nav-bar/nav-bar.component.html +++ b/overlord/src/app/core/nav-bar/nav-bar.component.html @@ -74,8 +74,7 @@ account_box {{ name }} - } - @if ((auth.currentUser | async) === null) { + } @else { account_box Login { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientModule], + providers: [TagService], + }); + }); + + it('should be created', inject([TagService], (service: TagService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/overlord/src/app/core/tag.service.ts b/overlord/src/app/core/tag.service.ts new file mode 100644 index 00000000..2429b550 --- /dev/null +++ b/overlord/src/app/core/tag.service.ts @@ -0,0 +1,64 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { catchError } from 'rxjs/operators'; + +import { ErrorLoggerService } from './error-logger.service'; +import { Tag } from './tag'; + +const url = '/api/tags'; +const serviceName = 'TagService'; + +@Injectable({ + providedIn: 'root', +}) +export class TagService { + constructor( + private http: HttpClient, + private log: ErrorLoggerService, + ) {} + + get(id: string): Observable { + return this.http + .get(`${url}/${id}`) + .pipe(catchError(this.log.handleError(serviceName, 'Get Tag'))) as Observable; + } + + list(): Observable { + return this.http + .get(`${url}/list`) + .pipe(catchError(this.log.handleError(serviceName, 'List Tag'))) as Observable; + } + + autocomplete(query: string): Observable { + const options = { params: new HttpParams().set('q', query) }; + return this.http + .get(`${url}/query`, options) + .pipe(catchError(this.log.handleError(serviceName, 'autocomplete'))) as Observable; + } + + delete(id: string): Observable { + return this.http + .delete(`${url}/${id}`) + .pipe(catchError(this.log.handleError(serviceName, 'Delete Tag'))) as Observable; + } + + saveOrUpdate(tag: Tag): Observable { + if (!tag.id) { + return this.save(tag); + } + return this.update(tag); + } + + save(tag: Tag): Observable { + return this.http + .post(`${url}`, tag) + .pipe(catchError(this.log.handleError(serviceName, 'Save Tag'))) as Observable; + } + + update(tag: Tag): Observable { + return this.http + .put(`${url}/${tag.id}`, tag) + .pipe(catchError(this.log.handleError(serviceName, 'Update Tag'))) as Observable; + } +} diff --git a/overlord/src/app/core/tag.ts b/overlord/src/app/core/tag.ts new file mode 100644 index 00000000..a0fb7ba9 --- /dev/null +++ b/overlord/src/app/core/tag.ts @@ -0,0 +1,10 @@ +export class Tag { + id: string | null; + name: string; + + public constructor(init?: Partial) { + this.id = null; + this.name = ''; + Object.assign(this, init); + } +} diff --git a/overlord/src/app/core/voucher.ts b/overlord/src/app/core/voucher.ts index d876704c..8bcaec07 100644 --- a/overlord/src/app/core/voucher.ts +++ b/overlord/src/app/core/voucher.ts @@ -5,6 +5,7 @@ import { EmployeeBenefit } from './employee-benefit'; import { Incentive } from './incentive'; import { Inventory } from './inventory'; import { Journal } from './journal'; +import { Tag } from './tag'; import { User } from './user'; export class Voucher { @@ -22,6 +23,7 @@ export class Voucher { employeeBenefits: EmployeeBenefit[]; incentives: Incentive[]; files: DbFile[]; + tags: Tag[]; creationDate: string; lastEditDate: string; user: User; @@ -40,6 +42,7 @@ export class Voucher { this.employeeBenefits = []; this.incentives = []; this.files = []; + this.tags = []; this.creationDate = ''; this.lastEditDate = ''; this.user = new User(); diff --git a/overlord/src/app/journal/journal.component.html b/overlord/src/app/journal/journal.component.html index fe5d6113..ee02ee2c 100644 --- a/overlord/src/app/journal/journal.component.html +++ b/overlord/src/app/journal/journal.component.html @@ -110,6 +110,33 @@ Narration + + Tags + + @for (tag of voucher.tags; track tag) { + + {{ tag.name }} + + + } + + + + @for (tag of tags | async; track tag) { + {{ tag.name }} + } + +
@for (item of voucher.files; track item) {
diff --git a/overlord/src/app/journal/journal.component.ts b/overlord/src/app/journal/journal.component.ts index b23fb945..241d33cd 100644 --- a/overlord/src/app/journal/journal.component.ts +++ b/overlord/src/app/journal/journal.component.ts @@ -1,13 +1,15 @@ +import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { AfterViewInit, Component, ElementRef, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatChipInputEvent } from '@angular/material/chips'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { Hotkey, HotkeysService } from 'angular2-hotkeys'; import { round } from 'mathjs'; import moment from 'moment'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { AuthService } from '../auth/auth.service'; import { Account } from '../core/account'; @@ -15,6 +17,8 @@ import { AccountBalance } from '../core/account-balance'; import { AccountService } from '../core/account.service'; import { DbFile } from '../core/db-file'; import { Journal } from '../core/journal'; +import { Tag } from '../core/tag'; +import { TagService } from '../core/tag.service'; import { ToasterService } from '../core/toaster.service'; import { User } from '../core/user'; import { Voucher } from '../core/voucher'; @@ -35,6 +39,8 @@ import { JournalDialogComponent } from './journal-dialog.component'; export class JournalComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('accountElement', { static: true }) accountElement?: ElementRef; @ViewChild('dateElement', { static: true }) dateElement?: ElementRef; + @ViewChild('tagInput') tagInput?: ElementRef; + separatorKeysCodes: number[] = [ENTER, COMMA]; public journalObservable = new BehaviorSubject([]); dataSource: JournalDataSource = new JournalDataSource(this.journalObservable); form: FormGroup<{ @@ -45,9 +51,11 @@ export class JournalComponent implements OnInit, AfterViewInit, OnDestroy { amount: FormControl; }>; narration: FormControl; + tags: FormControl; }>; voucher: Voucher = new Voucher(); + tags: Observable; account: Account | null; accBal: AccountBalance | null = null; @@ -66,6 +74,7 @@ export class JournalComponent implements OnInit, AfterViewInit, OnDestroy { public image: ImageService, private ser: VoucherService, private accountSer: AccountService, + private tagSer: TagService, ) { this.account = null; this.form = new FormGroup({ @@ -76,6 +85,7 @@ export class JournalComponent implements OnInit, AfterViewInit, OnDestroy { amount: new FormControl('', { nonNullable: true }), }), narration: new FormControl('', { nonNullable: true }), + tags: new FormControl(''), }); this.accBal = null; // Setup Account Autocomplete @@ -84,6 +94,12 @@ export class JournalComponent implements OnInit, AfterViewInit, OnDestroy { distinctUntilChanged(), switchMap((x) => (x === null ? observableOf([]) : this.accountSer.autocomplete(x))), ); + this.tags = this.form.controls.tags.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + map((tag: string | Tag | null) => (tag === null ? '' : typeof tag !== 'string' ? tag.name.toLowerCase() : tag)), + switchMap((tag: string) => this.tagSer.autocomplete(tag)), + ); } ngOnInit() { @@ -150,6 +166,7 @@ export class JournalComponent implements OnInit, AfterViewInit, OnDestroy { amount: '', }, narration: this.voucher.narration, + tags: '', }); this.dataSource = new JournalDataSource(this.journalObservable); this.journalObservable.next(this.voucher.journals); @@ -338,4 +355,38 @@ export class JournalComponent implements OnInit, AfterViewInit, OnDestroy { const index = this.voucher.files.indexOf(file); this.voucher.files.splice(index, 1); } + + removeTag(tag: Tag): void { + const index = this.voucher.tags.indexOf(tag); + + if (index >= 0) { + this.voucher.tags.splice(index, 1); + } + } + + addTag(event: MatChipInputEvent): void { + const value = (event.value || '').trim(); + + // Add our tag + if (value) { + this.voucher.tags.push(new Tag({ name: value })); + } + + // Clear the input value + event.chipInput!.clear(); + + this.form.controls.tags.setValue(null); + } + + selectedTag(event: MatAutocompleteSelectedEvent): void { + const tag = event.option.value as Tag; + const index = this.voucher.tags.findIndex((t) => (tag.id === null ? t.name === tag.name : t.id === tag.id)); + if (index === -1) { + this.voucher.tags.push(tag); + } + if (this.tagInput) { + this.tagInput.nativeElement.value = ''; + } + this.form.controls.tags.setValue(null); + } } diff --git a/overlord/src/app/journal/journal.module.ts b/overlord/src/app/journal/journal.module.ts index f04eb5e2..92982009 100644 --- a/overlord/src/app/journal/journal.module.ts +++ b/overlord/src/app/journal/journal.module.ts @@ -7,6 +7,7 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatChipsModule } from '@angular/material/chips'; import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE, MatNativeDateModule } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDialogModule } from '@angular/material/dialog'; @@ -49,6 +50,7 @@ export const MY_FORMATS = { MatButtonModule, MatCardModule, MatCheckboxModule, + MatChipsModule, MatDatepickerModule, MatDialogModule, MatFormFieldModule, diff --git a/overlord/src/app/payment/payment.component.html b/overlord/src/app/payment/payment.component.html index aabbeb57..7fc8cdb3 100644 --- a/overlord/src/app/payment/payment.component.html +++ b/overlord/src/app/payment/payment.component.html @@ -113,6 +113,33 @@ Narration + + Tags + + @for (tag of voucher.tags; track tag) { + + {{ tag.name }} + + + } + + + + @for (tag of tags | async; track tag) { + {{ tag.name }} + } + +
@for (item of voucher.files; track item) {
diff --git a/overlord/src/app/payment/payment.component.ts b/overlord/src/app/payment/payment.component.ts index d1dc3abd..eb736749 100644 --- a/overlord/src/app/payment/payment.component.ts +++ b/overlord/src/app/payment/payment.component.ts @@ -1,13 +1,15 @@ +import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { AfterViewInit, Component, ElementRef, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatChipInputEvent } from '@angular/material/chips'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { Hotkey, HotkeysService } from 'angular2-hotkeys'; import { round } from 'mathjs'; import moment from 'moment'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { AuthService } from '../auth/auth.service'; import { Account } from '../core/account'; @@ -15,6 +17,8 @@ import { AccountBalance } from '../core/account-balance'; import { AccountService } from '../core/account.service'; import { DbFile } from '../core/db-file'; import { Journal } from '../core/journal'; +import { Tag } from '../core/tag'; +import { TagService } from '../core/tag.service'; import { ToasterService } from '../core/toaster.service'; import { User } from '../core/user'; import { Voucher } from '../core/voucher'; @@ -35,6 +39,8 @@ import { PaymentDialogComponent } from './payment-dialog.component'; export class PaymentComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('accountElement', { static: true }) accountElement?: ElementRef; @ViewChild('dateElement', { static: true }) dateElement?: ElementRef; + @ViewChild('tagInput') tagInput?: ElementRef; + separatorKeysCodes: number[] = [ENTER, COMMA]; public journalObservable = new BehaviorSubject([]); dataSource: PaymentDataSource = new PaymentDataSource(this.journalObservable); form: FormGroup<{ @@ -46,6 +52,7 @@ export class PaymentComponent implements OnInit, AfterViewInit, OnDestroy { amount: FormControl; }>; narration: FormControl; + tags: FormControl; }>; paymentAccounts: Account[] = []; @@ -57,6 +64,7 @@ export class PaymentComponent implements OnInit, AfterViewInit, OnDestroy { displayedColumns = ['account', 'amount', 'action']; accounts: Observable; + tags: Observable; constructor( private route: ActivatedRoute, @@ -69,6 +77,7 @@ export class PaymentComponent implements OnInit, AfterViewInit, OnDestroy { public image: ImageService, private ser: VoucherService, private accountSer: AccountService, + private tagSer: TagService, ) { this.account = null; this.form = new FormGroup({ @@ -80,6 +89,7 @@ export class PaymentComponent implements OnInit, AfterViewInit, OnDestroy { amount: new FormControl('', { nonNullable: true }), }), narration: new FormControl('', { nonNullable: true }), + tags: new FormControl(''), }); this.accBal = null; // Listen to Account Autocomplete Change @@ -88,6 +98,12 @@ export class PaymentComponent implements OnInit, AfterViewInit, OnDestroy { distinctUntilChanged(), switchMap((x) => (x === null ? observableOf([]) : this.accountSer.autocomplete(x))), ); + this.tags = this.form.controls.tags.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + map((tag: string | Tag | null) => (tag === null ? '' : typeof tag !== 'string' ? tag.name.toLowerCase() : tag)), + switchMap((tag: string) => this.tagSer.autocomplete(tag)), + ); // Listen to Payment Account Change this.form.controls.paymentAccount.valueChanges.subscribe((x) => this.router.navigate([], { @@ -165,6 +181,7 @@ export class PaymentComponent implements OnInit, AfterViewInit, OnDestroy { amount: '', }, narration: this.voucher.narration, + tags: '', }); this.dataSource = new PaymentDataSource(this.journalObservable); this.updateView(); @@ -349,4 +366,38 @@ export class PaymentComponent implements OnInit, AfterViewInit, OnDestroy { const index = this.voucher.files.indexOf(file); this.voucher.files.splice(index, 1); } + + removeTag(tag: Tag): void { + const index = this.voucher.tags.indexOf(tag); + + if (index >= 0) { + this.voucher.tags.splice(index, 1); + } + } + + addTag(event: MatChipInputEvent): void { + const value = (event.value || '').trim(); + + // Add our tag + if (value) { + this.voucher.tags.push(new Tag({ name: value })); + } + + // Clear the input value + event.chipInput!.clear(); + + this.form.controls.tags.setValue(null); + } + + selectedTag(event: MatAutocompleteSelectedEvent): void { + const tag = event.option.value as Tag; + const index = this.voucher.tags.findIndex((t) => (tag.id === null ? t.name === tag.name : t.id === tag.id)); + if (index === -1) { + this.voucher.tags.push(tag); + } + if (this.tagInput) { + this.tagInput.nativeElement.value = ''; + } + this.form.controls.tags.setValue(null); + } } diff --git a/overlord/src/app/payment/payment.module.ts b/overlord/src/app/payment/payment.module.ts index 3086d45b..8f46d971 100644 --- a/overlord/src/app/payment/payment.module.ts +++ b/overlord/src/app/payment/payment.module.ts @@ -7,6 +7,7 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatChipsModule } from '@angular/material/chips'; import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE, MatNativeDateModule } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDialogModule } from '@angular/material/dialog'; @@ -49,6 +50,7 @@ export const MY_FORMATS = { MatButtonModule, MatCardModule, MatCheckboxModule, + MatChipsModule, MatDatepickerModule, MatDialogModule, MatFormFieldModule, 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 a7f687b6..d797f972 100644 --- a/overlord/src/app/product/product-detail/product-detail.component.html +++ b/overlord/src/app/product/product-detail/product-detail.component.html @@ -51,8 +51,6 @@
@if (item.productGroup?.nutritional ?? false) {

Nutritional Information

- } - @if (item.productGroup?.nutritional ?? false) {
Protein @@ -94,8 +92,6 @@ } @if (item.productGroup?.iceCream ?? false) {

Ice Cream Information

- } - @if (item.productGroup?.iceCream ?? false) {
MSNF diff --git a/overlord/src/app/purchase-return/purchase-return.component.html b/overlord/src/app/purchase-return/purchase-return.component.html index 3bace13e..be34d8c7 100644 --- a/overlord/src/app/purchase-return/purchase-return.component.html +++ b/overlord/src/app/purchase-return/purchase-return.component.html @@ -148,6 +148,33 @@ Narration + + Tags + + @for (tag of voucher.tags; track tag) { + + {{ tag.name }} + + + } + + + + @for (tag of tags | async; track tag) { + {{ tag.name }} + } + +
@for (item of voucher.files; track item) {
diff --git a/overlord/src/app/purchase-return/purchase-return.component.ts b/overlord/src/app/purchase-return/purchase-return.component.ts index 42ebae79..38c7d60a 100644 --- a/overlord/src/app/purchase-return/purchase-return.component.ts +++ b/overlord/src/app/purchase-return/purchase-return.component.ts @@ -1,13 +1,15 @@ +import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { AfterViewInit, Component, ElementRef, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatChipInputEvent } from '@angular/material/chips'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { Hotkey, HotkeysService } from 'angular2-hotkeys'; import { round } from 'mathjs'; import moment from 'moment'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { AuthService } from '../auth/auth.service'; import { Account } from '../core/account'; @@ -17,6 +19,8 @@ import { Batch } from '../core/batch'; import { BatchService } from '../core/batch.service'; import { DbFile } from '../core/db-file'; import { Inventory } from '../core/inventory'; +import { Tag } from '../core/tag'; +import { TagService } from '../core/tag.service'; import { ToasterService } from '../core/toaster.service'; import { User } from '../core/user'; import { Voucher } from '../core/voucher'; @@ -38,6 +42,8 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy @ViewChild('accountElement', { static: true }) accountElement?: ElementRef; @ViewChild('batchElement', { static: true }) batchElement?: ElementRef; @ViewChild('dateElement', { static: true }) dateElement?: ElementRef; + @ViewChild('tagInput') tagInput?: ElementRef; + separatorKeysCodes: number[] = [ENTER, COMMA]; public inventoryObservable = new BehaviorSubject([]); dataSource: PurchaseReturnDataSource = new PurchaseReturnDataSource(this.inventoryObservable); form: FormGroup<{ @@ -49,6 +55,7 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy quantity: FormControl; }>; narration: FormControl; + tags: FormControl; }>; voucher: Voucher = new Voucher(); @@ -58,6 +65,7 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy displayedColumns = ['product', 'quantity', 'rate', 'tax', 'discount', 'amount', 'action']; accounts: Observable; + tags: Observable; batches: Observable; constructor( @@ -72,6 +80,7 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy private ser: VoucherService, private batchSer: BatchService, private accountSer: AccountService, + private tagSer: TagService, ) { this.form = new FormGroup({ date: new FormControl(new Date(), { nonNullable: true }), @@ -82,6 +91,7 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy quantity: new FormControl('', { nonNullable: true }), }), narration: new FormControl('', { nonNullable: true }), + tags: new FormControl(''), }); // Listen to Account Autocomplete Change this.accounts = this.form.controls.account.valueChanges.pipe( @@ -89,6 +99,12 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy distinctUntilChanged(), switchMap((x) => (x === null ? observableOf([]) : this.accountSer.autocomplete(x))), ); + this.tags = this.form.controls.tags.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + map((tag: string | Tag | null) => (tag === null ? '' : typeof tag !== 'string' ? tag.name.toLowerCase() : tag)), + switchMap((tag: string) => this.tagSer.autocomplete(tag)), + ); // Listen to Batch Autocomplete Change this.batches = this.form.controls.addRow.controls.batch.valueChanges.pipe( debounceTime(150), @@ -166,6 +182,7 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy quantity: '', }, narration: this.voucher.narration, + tags: '', }); this.dataSource = new PurchaseReturnDataSource(this.inventoryObservable); this.updateView(); @@ -351,4 +368,38 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy const index = this.voucher.files.indexOf(file); this.voucher.files.splice(index, 1); } + + removeTag(tag: Tag): void { + const index = this.voucher.tags.indexOf(tag); + + if (index >= 0) { + this.voucher.tags.splice(index, 1); + } + } + + addTag(event: MatChipInputEvent): void { + const value = (event.value || '').trim(); + + // Add our tag + if (value) { + this.voucher.tags.push(new Tag({ name: value })); + } + + // Clear the input value + event.chipInput!.clear(); + + this.form.controls.tags.setValue(null); + } + + selectedTag(event: MatAutocompleteSelectedEvent): void { + const tag = event.option.value as Tag; + const index = this.voucher.tags.findIndex((t) => (tag.id === null ? t.name === tag.name : t.id === tag.id)); + if (index === -1) { + this.voucher.tags.push(tag); + } + if (this.tagInput) { + this.tagInput.nativeElement.value = ''; + } + this.form.controls.tags.setValue(null); + } } diff --git a/overlord/src/app/purchase-return/purchase-return.module.ts b/overlord/src/app/purchase-return/purchase-return.module.ts index 82075e69..b328c5ed 100644 --- a/overlord/src/app/purchase-return/purchase-return.module.ts +++ b/overlord/src/app/purchase-return/purchase-return.module.ts @@ -7,6 +7,7 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatChipsModule } from '@angular/material/chips'; import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE, MatNativeDateModule } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDialogModule } from '@angular/material/dialog'; @@ -49,6 +50,7 @@ export const MY_FORMATS = { MatButtonModule, MatCardModule, MatCheckboxModule, + MatChipsModule, MatDatepickerModule, MatDialogModule, MatFormFieldModule, diff --git a/overlord/src/app/purchase/purchase.component.html b/overlord/src/app/purchase/purchase.component.html index 060c6fbd..b254988e 100644 --- a/overlord/src/app/purchase/purchase.component.html +++ b/overlord/src/app/purchase/purchase.component.html @@ -160,6 +160,33 @@ Narration + + Tags + + @for (tag of voucher.tags; track tag) { + + {{ tag.name }} + + + } + + + + @for (tag of tags | async; track tag) { + {{ tag.name }} + } + +
@for (item of voucher.files; track item) {
diff --git a/overlord/src/app/purchase/purchase.component.ts b/overlord/src/app/purchase/purchase.component.ts index d8e1b6dd..f0855908 100644 --- a/overlord/src/app/purchase/purchase.component.ts +++ b/overlord/src/app/purchase/purchase.component.ts @@ -1,13 +1,15 @@ +import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { AfterViewInit, Component, ElementRef, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatChipInputEvent } from '@angular/material/chips'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { Hotkey, HotkeysService } from 'angular2-hotkeys'; import { round } from 'mathjs'; import moment from 'moment'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { AuthService } from '../auth/auth.service'; import { Account } from '../core/account'; @@ -18,6 +20,8 @@ import { DbFile } from '../core/db-file'; import { Inventory } from '../core/inventory'; import { Product } from '../core/product'; import { ProductSku } from '../core/product-sku'; +import { Tag } from '../core/tag'; +import { TagService } from '../core/tag.service'; import { ToasterService } from '../core/toaster.service'; import { User } from '../core/user'; import { Voucher } from '../core/voucher'; @@ -40,6 +44,8 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('accountElement', { static: true }) accountElement?: ElementRef; @ViewChild('productElement', { static: true }) productElement?: ElementRef; @ViewChild('dateElement', { static: true }) dateElement?: ElementRef; + @ViewChild('tagInput') tagInput?: ElementRef; + separatorKeysCodes: number[] = [ENTER, COMMA]; public inventoryObservable = new BehaviorSubject([]); dataSource: PurchaseDataSource = new PurchaseDataSource(this.inventoryObservable); form: FormGroup<{ @@ -54,6 +60,7 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { discount: FormControl; }>; narration: FormControl; + tags: FormControl; }>; voucher: Voucher = new Voucher(); @@ -63,6 +70,7 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { displayedColumns = ['product', 'quantity', 'rate', 'tax', 'discount', 'amount', 'action']; accounts: Observable; + tags: Observable; products: Observable; constructor( @@ -77,6 +85,7 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { private ser: VoucherService, private productSer: ProductService, private accountSer: AccountService, + private tagSer: TagService, ) { this.form = new FormGroup({ date: new FormControl(new Date(), { nonNullable: true }), @@ -90,6 +99,7 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { discount: new FormControl('', { nonNullable: true }), }), narration: new FormControl('', { nonNullable: true }), + tags: new FormControl(''), }); this.accBal = null; // Listen to Account Autocomplete Change @@ -98,6 +108,12 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { distinctUntilChanged(), switchMap((x) => (x === null ? observableOf([]) : this.accountSer.autocomplete(x))), ); + this.tags = this.form.controls.tags.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + map((tag: string | Tag | null) => (tag === null ? '' : typeof tag !== 'string' ? tag.name.toLowerCase() : tag)), + switchMap((tag: string) => this.tagSer.autocomplete(tag)), + ); // Listen to Product Autocomplete Change this.products = this.form.controls.addRow.controls.product.valueChanges.pipe( debounceTime(150), @@ -184,6 +200,7 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { discount: '', }, narration: this.voucher.narration, + tags: '', }); this.dataSource = new PurchaseDataSource(this.inventoryObservable); this.updateView(); @@ -393,4 +410,38 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { const index = this.voucher.files.indexOf(file); this.voucher.files.splice(index, 1); } + + removeTag(tag: Tag): void { + const index = this.voucher.tags.indexOf(tag); + + if (index >= 0) { + this.voucher.tags.splice(index, 1); + } + } + + addTag(event: MatChipInputEvent): void { + const value = (event.value || '').trim(); + + // Add our tag + if (value) { + this.voucher.tags.push(new Tag({ name: value })); + } + + // Clear the input value + event.chipInput!.clear(); + + this.form.controls.tags.setValue(null); + } + + selectedTag(event: MatAutocompleteSelectedEvent): void { + const tag = event.option.value as Tag; + const index = this.voucher.tags.findIndex((t) => (tag.id === null ? t.name === tag.name : t.id === tag.id)); + if (index === -1) { + this.voucher.tags.push(tag); + } + if (this.tagInput) { + this.tagInput.nativeElement.value = ''; + } + this.form.controls.tags.setValue(null); + } } diff --git a/overlord/src/app/purchase/purchase.module.ts b/overlord/src/app/purchase/purchase.module.ts index 9b272815..83366640 100644 --- a/overlord/src/app/purchase/purchase.module.ts +++ b/overlord/src/app/purchase/purchase.module.ts @@ -7,6 +7,7 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatChipsModule } from '@angular/material/chips'; import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE, MatNativeDateModule } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDialogModule } from '@angular/material/dialog'; @@ -49,6 +50,7 @@ export const MY_FORMATS = { MatButtonModule, MatCardModule, MatCheckboxModule, + MatChipsModule, MatDatepickerModule, MatDialogModule, MatFormFieldModule, diff --git a/overlord/src/app/receipt/receipt.component.html b/overlord/src/app/receipt/receipt.component.html index 0e476354..1aaf1fdf 100644 --- a/overlord/src/app/receipt/receipt.component.html +++ b/overlord/src/app/receipt/receipt.component.html @@ -113,6 +113,33 @@ Narration + + Tags + + @for (tag of voucher.tags; track tag) { + + {{ tag.name }} + + + } + + + + @for (tag of tags | async; track tag) { + {{ tag.name }} + } + +
@for (item of voucher.files; track item) {
diff --git a/overlord/src/app/receipt/receipt.component.ts b/overlord/src/app/receipt/receipt.component.ts index b6cabbd5..a0880fb5 100644 --- a/overlord/src/app/receipt/receipt.component.ts +++ b/overlord/src/app/receipt/receipt.component.ts @@ -1,13 +1,15 @@ +import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { AfterViewInit, Component, ElementRef, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatChipInputEvent } from '@angular/material/chips'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { Hotkey, HotkeysService } from 'angular2-hotkeys'; import { round } from 'mathjs'; import moment from 'moment'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { AuthService } from '../auth/auth.service'; import { Account } from '../core/account'; @@ -15,6 +17,8 @@ import { AccountBalance } from '../core/account-balance'; import { AccountService } from '../core/account.service'; import { DbFile } from '../core/db-file'; import { Journal } from '../core/journal'; +import { Tag } from '../core/tag'; +import { TagService } from '../core/tag.service'; import { ToasterService } from '../core/toaster.service'; import { User } from '../core/user'; import { Voucher } from '../core/voucher'; @@ -35,6 +39,8 @@ import { ReceiptDialogComponent } from './receipt-dialog.component'; export class ReceiptComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('accountElement', { static: true }) accountElement?: ElementRef; @ViewChild('dateElement', { static: true }) dateElement?: ElementRef; + @ViewChild('tagInput') tagInput?: ElementRef; + separatorKeysCodes: number[] = [ENTER, COMMA]; public journalObservable = new BehaviorSubject([]); dataSource: ReceiptDataSource = new ReceiptDataSource(this.journalObservable); form: FormGroup<{ @@ -46,6 +52,7 @@ export class ReceiptComponent implements OnInit, AfterViewInit, OnDestroy { amount: FormControl; }>; narration: FormControl; + tags: FormControl; }>; receiptAccounts: Account[] = []; @@ -57,6 +64,7 @@ export class ReceiptComponent implements OnInit, AfterViewInit, OnDestroy { displayedColumns = ['account', 'amount', 'action']; accounts: Observable; + tags: Observable; constructor( private route: ActivatedRoute, @@ -69,6 +77,7 @@ export class ReceiptComponent implements OnInit, AfterViewInit, OnDestroy { public image: ImageService, private ser: VoucherService, private accountSer: AccountService, + private tagSer: TagService, ) { this.form = new FormGroup({ date: new FormControl(new Date(), { nonNullable: true }), @@ -79,6 +88,7 @@ export class ReceiptComponent implements OnInit, AfterViewInit, OnDestroy { amount: new FormControl('', { nonNullable: true }), }), narration: new FormControl('', { nonNullable: true }), + tags: new FormControl(''), }); this.accBal = null; // Listen to Account Autocomplete Change @@ -87,6 +97,12 @@ export class ReceiptComponent implements OnInit, AfterViewInit, OnDestroy { distinctUntilChanged(), switchMap((x) => (x === null ? observableOf([]) : this.accountSer.autocomplete(x))), ); + this.tags = this.form.controls.tags.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + map((tag: string | Tag | null) => (tag === null ? '' : typeof tag !== 'string' ? tag.name.toLowerCase() : tag)), + switchMap((tag: string) => this.tagSer.autocomplete(tag)), + ); // Listen to Receipt Account Change this.form.controls.receiptAccount.valueChanges.subscribe((x) => this.router.navigate([], { @@ -164,6 +180,7 @@ export class ReceiptComponent implements OnInit, AfterViewInit, OnDestroy { amount: '', }, narration: this.voucher.narration, + tags: '', }); this.dataSource = new ReceiptDataSource(this.journalObservable); this.updateView(); @@ -348,4 +365,38 @@ export class ReceiptComponent implements OnInit, AfterViewInit, OnDestroy { const index = this.voucher.files.indexOf(file); this.voucher.files.splice(index, 1); } + + removeTag(tag: Tag): void { + const index = this.voucher.tags.indexOf(tag); + + if (index >= 0) { + this.voucher.tags.splice(index, 1); + } + } + + addTag(event: MatChipInputEvent): void { + const value = (event.value || '').trim(); + + // Add our tag + if (value) { + this.voucher.tags.push(new Tag({ name: value })); + } + + // Clear the input value + event.chipInput!.clear(); + + this.form.controls.tags.setValue(null); + } + + selectedTag(event: MatAutocompleteSelectedEvent): void { + const tag = event.option.value as Tag; + const index = this.voucher.tags.findIndex((t) => (tag.id === null ? t.name === tag.name : t.id === tag.id)); + if (index === -1) { + this.voucher.tags.push(tag); + } + if (this.tagInput) { + this.tagInput.nativeElement.value = ''; + } + this.form.controls.tags.setValue(null); + } } diff --git a/overlord/src/app/receipt/receipt.module.ts b/overlord/src/app/receipt/receipt.module.ts index 662172c5..19aa2583 100644 --- a/overlord/src/app/receipt/receipt.module.ts +++ b/overlord/src/app/receipt/receipt.module.ts @@ -7,6 +7,7 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatChipsModule } from '@angular/material/chips'; import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE, MatNativeDateModule } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDialogModule } from '@angular/material/dialog'; @@ -49,6 +50,7 @@ export const MY_FORMATS = { MatButtonModule, MatCardModule, MatCheckboxModule, + MatChipsModule, MatDatepickerModule, MatDialogModule, MatFormFieldModule,