All voucher routes now work with bundles.

Need to check / update all reports and prints
This commit is contained in:
2026-02-05 00:46:49 +00:00
parent 6e8843d3c4
commit 8957e4ce8c
53 changed files with 1133 additions and 899 deletions

View File

@ -1,34 +0,0 @@
import { Modifier } from './modifier';
export class BillViewItem {
id: string | undefined;
kotId: string | undefined;
isKot: boolean;
info: string;
skuId: string;
isHappyHour: boolean;
isPrinted: boolean;
quantity: number;
modifiers: Modifier[];
public get isOldKot(): boolean {
return this.isKot && this.kotId !== undefined;
}
public get isNewKot(): boolean {
return this.isKot && this.kotId === undefined;
}
public constructor(init?: Partial<BillViewItem>) {
this.isKot = true;
this.info = '';
this.kotId = '';
this.skuId = '';
this.isHappyHour = false;
this.isPrinted = false;
this.quantity = 0;
this.modifiers = [];
Object.assign(this, init);
}
}

View File

@ -2,17 +2,35 @@ import { MenuCategory } from './menu-category';
import { SaleCategory } from './sale-category';
import { Tax } from './tax';
export class BundleItemQuery {
id: string;
name: string;
price: number;
quantity: number;
tax: Tax;
public constructor(init?: Partial<BundleItemQuery>) {
this.id = '';
this.name = '';
this.price = 0;
this.quantity = 1;
this.tax = new Tax();
Object.assign(this, init);
}
}
export class ProductQuery {
id: string | undefined;
name: string;
price: number;
hasHappyHour: boolean;
isNotAvailable: boolean;
isBundle: boolean;
sortOrder: number;
menuCategory?: MenuCategory;
saleCategory?: SaleCategory;
tax: Tax;
bundleItems: BundleItemQuery[];
public constructor(init?: Partial<ProductQuery>) {
this.id = undefined;
@ -20,8 +38,10 @@ export class ProductQuery {
this.price = 0;
this.hasHappyHour = false;
this.isNotAvailable = false;
this.isBundle = false;
this.sortOrder = 0;
this.tax = new Tax();
this.bundleItems = [];
Object.assign(this, init);
}
}

View File

@ -3,19 +3,19 @@ import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { BehaviorSubject, throwError, Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { map, tap } from 'rxjs/operators';
import { BillViewItem } from '../core/bill-view-item';
import { ModifierCategory } from '../core/modifier-category';
import { ProductQuery } from '../core/product-query';
import { ReceivePaymentItem } from '../core/receive-payment-item';
import { SaleCategory } from '../core/sale-category';
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 { BillRow } from './bills/bill-row';
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';
@ -30,66 +30,97 @@ export class BillService {
private ser = inject(VoucherService);
private modifierCategoryService = inject(ModifierCategoryService);
public dataObs: BehaviorSubject<BillViewItem[]>;
public dataObs: BehaviorSubject<Kot[]>;
public bill: Bill = new Bill();
private originalBill: Bill = new Bill();
public grossAmount: BehaviorSubject<number>;
public discountAmount: BehaviorSubject<number>;
public hhAmount: BehaviorSubject<number>;
public taxAmount: BehaviorSubject<number>;
public grossAmount: Observable<number>;
public discountAmount: Observable<number>;
public hhAmount: Observable<number>;
public taxAmount: Observable<number>;
public amount: Observable<number>;
public amountVal: number;
public selection = new SelectionModel<string>(true, []);
private amountBs: BehaviorSubject<number>;
private updateTable: boolean;
private allowDeactivate: boolean;
// To disable Deactivate Guard on navigation after printing bill or kot.
constructor() {
this.dataObs = new BehaviorSubject<BillViewItem[]>([]);
this.grossAmount = new BehaviorSubject(0);
this.discountAmount = new BehaviorSubject(0);
this.hhAmount = new BehaviorSubject(0);
this.taxAmount = new BehaviorSubject(0);
this.amountBs = new BehaviorSubject(0);
this.dataObs = new BehaviorSubject<Kot[]>([]);
this.amountVal = 0;
this.updateTable = true;
this.allowDeactivate = false;
this.amount = this.amountBs.pipe(tap((x) => (this.amountVal = x)));
this.grossAmount = this.dataObs.pipe(
map((kots: Kot[]) =>
this.math.halfRoundEven(
kots.reduce((t, k) => k.inventories.reduce((a, c) => a + c.price * c.quantity, 0) + t, 0),
),
),
);
this.hhAmount = this.dataObs.pipe(
map((kots: Kot[]) => {
return this.math.halfRoundEven(
kots.reduce(
(t, k) => k.inventories.reduce((a, c) => a + (c.isHappyHour ? c.price : 0) * c.quantity, 0) + t,
0,
),
);
}),
);
this.discountAmount = this.dataObs.pipe(
map((kots: Kot[]) => {
return this.math.halfRoundEven(
kots.reduce(
(t, k) =>
k.inventories.reduce((a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * c.discount, 0) + t,
0,
),
);
}),
);
this.taxAmount = this.dataObs.pipe(
map((kots: Kot[]) => {
return this.math.halfRoundEven(
kots.reduce(
(t, k) =>
k.inventories.reduce(
(a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * c.taxRate,
0,
) + t,
0,
),
);
}),
);
this.amount = this.dataObs.pipe(
map((kots: Kot[]) => {
return this.math.halfRoundEven(
kots.reduce(
(t, k) =>
k.inventories.reduce(
(a, c) =>
a +
this.math.halfRoundEven(
(c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * (1 + c.taxRate),
2,
),
0,
) + t,
0,
),
);
}),
);
}
displayBill(): void {
this.allowDeactivate = false;
const data = this.transformBillToView(this.bill);
this.dataObs.next(data);
this.updateAmounts();
}
transformBillToView(bill: Bill): BillViewItem[] {
return bill.kots
.map((k: Kot) => [
new BillViewItem({
kotId: k.id,
isKot: true,
info: k.id ? `Kot: ${k.code} / ${k.date} (${k.user.name})` : '== New Kot ==',
}),
...k.inventories.map(
(i) =>
new BillViewItem({
id: i.id,
kotId: k.id,
isKot: false,
skuId: i.sku.id,
isHappyHour: i.isHappyHour,
isPrinted: !!k.id,
info: `${i.sku.name} @ ${i.price} - ${this.math.halfRoundEven(i.discount * 100, 2)}%`,
quantity: i.quantity,
modifiers: i.modifiers,
}),
),
])
.reduce((a, c) => a.concat(c), []);
this.dataObs.next(this.bill.kots);
}
loadData(bill: Bill, updateTable: boolean): void {
@ -104,23 +135,29 @@ export class BillService {
minimum(skuId: string, happyHour: boolean): number {
return this.bill.kots.reduce(
(t, k) =>
k.inventories.reduce((a, c) => (c.sku.id === skuId && c.isHappyHour === happyHour ? a + c.quantity : a), 0) + t,
k.inventories
.filter((i) => i.sku.id === skuId && i.isHappyHour === happyHour)
.reduce((a, c) => a + c.quantity, 0) + t,
0,
);
}
addSku(sku: ProductLink, quantity: number, discount: number): void {
addOneSku(sku: ProductQuery): void {
const quantity = 1;
const discount = 0;
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) {
const minimum = this.minimum(sku.id as string, sku.hasHappyHour) + quantity;
if (minimum + quantity < 0) {
this.snackBar.open('Total quantity cannot be negative!', 'Error');
return;
}
}
const old = newKot.inventories.find(
(x) =>
x.sku.id === sku.id && x.isHappyHour === sku.hasHappyHour && x.type === (sku.isBundle ? 'bundle' : 'regular'),
);
if (old !== undefined) {
old.quantity += quantity;
if (old.children) {
old.children.forEach((child) => {
// child.
child.quantity += quantity * (old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0);
});
}
} else {
const item = new Inventory({
sku,
@ -131,7 +168,24 @@ export class BillService {
tax: sku.tax,
discount,
modifiers: [],
type: sku.isBundle ? 'bundle' : 'regular',
});
if (sku.isBundle) {
for (const bi of sku.bundleItems) {
const childItem = new Inventory({
sku: new ProductQuery(bi),
quantity: quantity * bi.quantity,
price: bi.price,
isHappyHour: sku.hasHappyHour,
taxRate: bi.tax?.rate,
tax: bi.tax,
discount: 0,
modifiers: [],
type: 'bundle_item',
});
item.children.push(childItem);
}
}
newKot.inventories.push(item);
this.modifierCategoryService.listForSku(sku.id as string).subscribe((result) => {
if (result.reduce((a: number, c: ModifierCategory) => a + c.minimum, 0)) {
@ -165,51 +219,56 @@ export class BillService {
});
}
addOne(item: BillViewItem): void {
const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot;
const old = newKot.inventories.find(
(x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour,
) as Inventory;
addOne(item: BillRow): void {
const old = item.inv as Inventory;
old.quantity += 1;
if (old.children) {
old.children.forEach((child) => {
child.quantity += old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0;
});
}
this.displayBill();
}
quantity(item: BillViewItem, quantity: number): void {
const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot;
const old = newKot.inventories.find(
(x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour,
) as Inventory;
quantity(item: BillRow, quantity: number): void {
const old = item.inv as Inventory;
old.quantity = quantity;
if (old.children) {
old.children.forEach((child) => {
child.quantity = (old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0) * quantity;
});
}
this.displayBill();
}
subtractOne(item: BillViewItem, canEdit: boolean): void {
const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot;
const old = newKot.inventories.find(
(x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour,
) as Inventory;
if (item.quantity >= 1 || (canEdit && this.minimum(item.skuId as string, item.isHappyHour) >= 1)) {
subtractOne(item: BillRow, canEdit: boolean): void {
const newKot = item.kot as Kot;
const old = item.inv as Inventory;
if (
old.quantity >= 1 ||
(canEdit && this.minimum(item.inv?.sku.id as string, item.inv?.isHappyHour || false) >= 1)
) {
old.quantity -= 1;
} else if (item.quantity === 0) {
if (old.children) {
old.children.forEach((child) => {
child.quantity -= old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0;
});
}
} else if (old.quantity === 0) {
newKot.inventories.splice(newKot.inventories.indexOf(old), 1);
}
this.displayBill();
}
removeItem(item: BillViewItem): void {
const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot;
const old = newKot.inventories.find(
(x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour,
) as Inventory;
removeItem(item: BillRow): void {
const newKot = item.kot as Kot;
const old = item.inv as Inventory;
newKot.inventories.splice(newKot.inventories.indexOf(old), 1);
this.displayBill();
}
modifier(item: BillViewItem): void {
const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot;
const old = newKot.inventories.find(
(x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour,
) as Inventory;
modifier(item: BillRow): void {
const old = item.inv as Inventory;
this.showModifier(old);
}
@ -217,11 +276,14 @@ export class BillService {
for (const kot of this.bill.kots) {
const noDiscount = kot.inventories.filter((x) => x.isHappyHour).map((x) => x.sku.id as string);
for (const inventory of kot.inventories) {
const e = discounts.find((d) => d.id === (inventory.sku.saleCategory as SaleCategory).id);
if (e === undefined || noDiscount.indexOf(inventory.sku.id as string) !== -1) {
if (noDiscount.indexOf(inventory.sku.id as string) !== -1) {
continue;
// No discount on happy hour items
}
const d = discounts.find((d) => d.id === (inventory.sku.saleCategory as SaleCategory).id);
if (d) {
inventory.discount = d.discount;
}
inventory.discount = e.discount;
}
}
this.displayBill();
@ -251,7 +313,7 @@ export class BillService {
if (!this.isBillDiffent(item, guestBookId)) {
return throwError(() => Error('Cannot print a blank KOT\nPlease add some products!'));
}
if (!this.happyHourItemsBalanced() || this.happyHourItemsMoreThanRegular()) {
if (!this.happyHourItemsBalanced() || !this.regularItemsMoreThanHappyHour()) {
return throwError(() => Error('Happy hour products are not balanced.'));
}
return this.ser
@ -265,7 +327,7 @@ export class BillService {
if (skus === 0) {
return throwError(() => Error('Cannot print a blank Bill\nPlease add some products!'));
}
if (!this.happyHourItemsBalanced() || this.happyHourItemsMoreThanRegular()) {
if (!this.happyHourItemsBalanced() || !this.regularItemsMoreThanHappyHour()) {
return throwError(() => Error('Happy hour products are not balanced.'));
}
return this.ser
@ -297,59 +359,6 @@ export class BillService {
return this.ser.cancelBill(this.bill.id as string, reason, this.updateTable);
}
updateAmounts() {
this.grossAmount.next(
this.math.halfRoundEven(
this.bill.kots.reduce((t, k) => k.inventories.reduce((a, c) => a + c.price * c.quantity, 0) + t, 0),
),
);
this.hhAmount.next(
this.math.halfRoundEven(
this.bill.kots.reduce(
(t, k) => k.inventories.reduce((a, c) => a + (c.isHappyHour ? c.price : 0) * c.quantity, 0) + t,
0,
),
),
);
this.discountAmount.next(
this.math.halfRoundEven(
this.bill.kots.reduce(
(t, k) => k.inventories.reduce((a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * c.discount, 0) + t,
0,
),
),
);
this.taxAmount.next(
this.math.halfRoundEven(
this.bill.kots.reduce(
(t, k) =>
k.inventories.reduce(
(a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * c.taxRate,
0,
) + t,
0,
),
),
);
this.amountBs.next(
this.math.halfRoundEven(
this.bill.kots.reduce(
(t, k) =>
k.inventories.reduce(
(a, c) =>
a +
this.math.halfRoundEven(
(c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * (1 + c.taxRate),
2,
),
0,
) + t,
0,
),
),
);
}
splitBill(inventories: string[], table: Table): Observable<boolean> {
return this.ser.splitBill(this.bill.id as string, inventories, table, this.updateTable);
}
@ -369,43 +378,49 @@ export class BillService {
}
private happyHourItemsBalanced(): boolean {
for (const kot of this.bill.kots) {
return this.bill.kots.every((kot) => {
const happyHourItems = kot.inventories
.filter((x) => x.isHappyHour)
.map((x) => ({ id: x.sku.id as string, quantity: x.quantity }));
for (const item of happyHourItems) {
const q = kot.inventories.find((x) => !x.isHappyHour && x.sku.id === item.id && x.quantity === item.quantity);
if (q === undefined) {
return false;
}
}
}
return true;
}
private happyHourItemsMoreThanRegular(): boolean {
// This is for the whole bill. eg. Kot 1 => Reg 2 + HH 2; Kot 2 => Reg 4; Kot 3 => Reg - 4
// This is pass okay in happy hours items balanced, but overall this is wrong. Hence this check
const invs: Record<string, { normal: number; happy: number }> = {};
for (const kot of this.bill.kots) {
for (const inventory of kot.inventories) {
const pid = inventory.sku.id as string;
if (invs[pid] === undefined) {
invs[pid] = { normal: 0, happy: 0 };
}
if (inventory.isHappyHour) {
invs[pid].happy += inventory.quantity;
} else {
invs[pid].normal += inventory.quantity;
}
}
}
for (const [, value] of Object.entries(invs)) {
if (value.happy > value.normal) {
.map((x) => ({ id: x.sku.id as string, quantity: x.quantity }))
.sort((a, b) => (a.id < b.id ? -1 : 1));
const happyHourIds = happyHourItems.map((x) => x.id);
const rest = kot.inventories
.filter((x) => !x.isHappyHour && happyHourIds.indexOf(x.sku.id as string) !== -1)
.map((x) => ({ id: x.sku.id as string, quantity: x.quantity }))
.sort((a, b) => (a.id < b.id ? -1 : 1));
if (happyHourIds.length === 0) {
return true;
}
}
return false;
return (
happyHourItems.length === rest.length &&
happyHourItems.every((v, i) => v.id === rest[i].id && v.quantity === rest[i].quantity)
);
});
}
private regularItemsMoreThanHappyHour(): boolean {
// This is for the whole bill. eg. Kot 1 => Reg 2 + HH 2; Kot 2 => Reg 4; Kot 3 => Reg - 4
// This is pass okay in happy hours items balanced, but overall this is wrong. Hence this check
const inventories = this.bill.kots
.flatMap((kot) => kot.inventories)
.reduce((acc: { id: string; quantity: number; isHappyHour: boolean }[], curr) => {
const existing = acc.find((x) => x.id === curr.sku.id && x.isHappyHour === curr.isHappyHour);
if (existing) {
existing.quantity += curr.quantity;
} else {
acc.push({ id: curr.sku.id as string, quantity: curr.quantity, isHappyHour: curr.isHappyHour });
}
return acc;
}, [])
.sort((a, b) => (a.id < b.id ? -1 : 1));
const happyHourItems = inventories.filter((x) => x.isHappyHour);
const happyHourIds = happyHourItems.map((x) => x.id);
const rest = inventories.filter((x) => !x.isHappyHour && happyHourIds.indexOf(x.id as string) !== -1);
return (
happyHourItems.length === rest.length &&
happyHourItems.every((hh, index) => hh.id === rest[index].id && hh.quantity <= rest[index].quantity)
);
}
public canDeactivate(): boolean {

View File

@ -0,0 +1,18 @@
import { Inventory } from './inventory';
import { Kot } from './kot';
export class BillRow {
id: string;
kind: 'kot' | 'inv' | 'child';
kot: Kot;
inv: Inventory | undefined;
parent: Inventory | undefined;
public constructor(init?: Partial<BillRow>) {
this.id = '';
this.kind = 'kot';
this.kot = new Kot();
this.inv = undefined;
this.parent = undefined;
Object.assign(this, init);
}
}

View File

@ -1,14 +0,0 @@
export class BillSelectionItem {
kotId?: string;
inventoryId?: string;
skuId: string;
isHappyHour: boolean;
public constructor(init?: Partial<BillSelectionItem>) {
this.kotId = undefined;
this.inventoryId = undefined;
this.skuId = '';
this.isHappyHour = false;
Object.assign(this, init);
}
}

View File

@ -1,15 +1,51 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable } from 'rxjs';
import { map, Observable, tap } from 'rxjs';
import { BillViewItem } from '../../core/bill-view-item';
import { BillRow } from './bill-row';
import { Kot } from './kot';
export class BillsDataSource extends DataSource<BillViewItem> {
constructor(private data: Observable<BillViewItem[]>) {
export class BillsDataSource extends DataSource<BillRow> {
private data: Kot[] = [];
constructor(private readonly dataObs: Observable<Kot[]>) {
super();
}
connect(): Observable<BillViewItem[]> {
return this.data;
connect(): Observable<BillRow[]> {
return this.dataObs.pipe(
tap((x) => {
this.data = x;
}),
map((d) =>
d.flatMap((k) => {
const kotRow: BillRow = new BillRow({ kind: 'kot', kot: k, id: k.id! });
const rows: BillRow[] = [];
for (const inv of k.inventories) {
if (!inv.id) {
inv.clientId = inv.clientId || crypto.randomUUID();
}
rows.push(new BillRow({ kind: 'inv', kot: k, inv, id: `${k.id}-${inv.id || inv.clientId}` }));
if (inv.type === 'bundle') {
for (const child of inv.children) {
if (!child.id) {
child.clientId = child.clientId || crypto.randomUUID();
}
child.parentId = inv.id || inv.clientId;
rows.push(
new BillRow({
kind: 'child',
kot: k,
parent: inv,
inv: child,
id: `${k.id}-${child.id || child.clientId}`,
}),
);
}
}
}
return [kotRow, ...rows];
}),
),
);
}
disconnect() {}

View File

@ -1,6 +1,5 @@
<h2>Bill</h2>
<table mat-table #table [dataSource]="dataSource" class="mat-elevation-z8" [trackBy]="trackByKotskuId">
<table mat-table #table [dataSource]="dataSource" class="mat-elevation-z8" [trackBy]="trackByRow">
<ng-container matColumnDef="bill-no-title">
<mat-header-cell *matHeaderCellDef class="deep-purple-200 bold">Bill / KOT number</mat-header-cell>
</ng-container>
@ -35,9 +34,9 @@
</ng-container>
<!-- Checkbox Column -->
<ng-container matColumnDef="select">
<ng-container matColumnDef="selectKot">
<mat-cell *matCellDef="let row">
@if (row.isOldKot) {
@if (row.id) {
<mat-checkbox
(change)="$event ? masterToggle(row) : null"
[checked]="bs.selection.hasValue() && isAllSelected(row)"
@ -45,71 +44,108 @@
>
</mat-checkbox>
}
@if (!row.isKot) {
<mat-checkbox
[disabled]="!row.id"
(click)="$event.stopPropagation()"
(change)="$event ? toggle(row) : null"
[checked]="isSelected(row)"
>
</mat-checkbox>
}
</mat-cell>
</ng-container>
<!-- Info Column -->
<ng-container matColumnDef="info">
<ng-container matColumnDef="selectInv">
<mat-cell *matCellDef="let row">
<span>
{{ row.info }}
</span>
<mat-checkbox
[disabled]="!row.id"
(click)="$event.stopPropagation()"
(change)="$event ? toggle(row) : null"
[checked]="isSelected(row)"
>
</mat-checkbox>
</mat-cell>
</ng-container>
<ng-container matColumnDef="selectChild">
<mat-cell *matCellDef="let row"> </mat-cell>
</ng-container>
<!-- Info Column -->
<ng-container matColumnDef="infoKot">
<mat-cell *matCellDef="let row">
<span>{{ row.id ? `Kot: ${row.kot.code} / ${row.kot.date} (${row.kot.user.name})` : '== New Kot ==' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="infoInv">
<mat-cell *matCellDef="let row">
<span>{{ `${row.inv.sku.name} @ ${row.inv.price} - ${discountPct(row.inv)}%` }}</span>
<ul>
@for (m of row.modifiers; track m.id) {
@for (m of row.inv.modifiers; track m.id) {
<li>{{ m.name }}</li>
}
</ul>
</mat-cell>
</ng-container>
<ng-container matColumnDef="infoChild">
<mat-cell *matCellDef="let row" class="child-product">
<span>{{ `${row.inv.sku.name} @ ${row.inv.price} - ${discountPct(row.inv)}%` }}</span>
<ul>
@for (m of row.inv.modifiers; track m.id) {
<li>{{ m.name }}</li>
}
</ul>
</mat-cell>
</ng-container>
<!-- Quantity Column -->
<ng-container matColumnDef="kotActions">
<mat-header-cell *matHeaderCellDef>Quantity</mat-header-cell>
<mat-cell *matCellDef="let row" class="right-align">
<button mat-icon-button (click)="moveKot(row)" [disabled]="!row.kotId">
<mat-icon class="del">open_in_new</mat-icon>
</button>
</mat-cell>
</ng-container>
<ng-container matColumnDef="quantity">
<mat-header-cell *matHeaderCellDef>Quantity</mat-header-cell>
<mat-cell *matCellDef="let row" class="right-align">
@if (!row.isKot) {
<button mat-icon-button class="small-icon-button" (click)="subtractOne(row)" [disabled]="row.isPrinted">
<mat-icon>indeterminate_check_box</mat-icon>
</button>
}
@if (!row.isKot) {
<button mat-icon-button class="small-icon-button" (click)="quantity(row)" [disabled]="rowQuantityDisabled(row)">
{{ row.quantity }}
</button>
}
@if (!row.isKot) {
<button mat-icon-button class="small-icon-button" (click)="addOne(row)" [disabled]="row.isPrinted">
<mat-icon>control_point</mat-icon>
</button>
}
@if (!row.isKot) {
<button mat-icon-button class="small-icon-button" (click)="modifier(row)" [disabled]="row.isPrinted">
<mat-icon>assignment</mat-icon>
</button>
}
@if (row.isKot) {
<button mat-icon-button (click)="moveKot(row)" [disabled]="row.isKot && !row.kotId">
<mat-icon class="del">open_in_new</mat-icon>
</button>
}
<button mat-icon-button class="small-icon-button" (click)="subtractOne(row)" [disabled]="!!row.kot.id">
<mat-icon>indeterminate_check_box</mat-icon>
</button>
<button mat-icon-button class="small-icon-button" (click)="quantity(row)" [disabled]="rowQuantityDisabled(row)">
{{ row.inv.quantity }}
</button>
<button mat-icon-button class="small-icon-button" (click)="addOne(row)" [disabled]="!!row.kot.id">
<mat-icon>control_point</mat-icon>
</button>
<button mat-icon-button class="small-icon-button" (click)="modifier(row)" [disabled]="!!row.kot.id">
<mat-icon>assignment</mat-icon>
</button>
</mat-cell>
</ng-container>
<ng-container matColumnDef="childQuantity">
<mat-header-cell *matHeaderCellDef>Quantity</mat-header-cell>
<mat-cell *matCellDef="let row" class="right-align">
<span mat-icon-button class="small-icon-button"> </span>
<button mat-icon-button class="small-icon-button" (click)="quantity(row)" [disabled]="true">
{{ row.inv.quantity }}
</button>
<span mat-icon-button class="small-icon-button"> </span>
<button mat-icon-button class="small-icon-button" (click)="modifier(row)" [disabled]="!!row.kot.id">
<mat-icon>assignment</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="['bill-no-title', 'bill-no-details']"></mat-header-row>
<mat-header-row *matHeaderRowDef="['time-title', 'time-details']"></mat-header-row>
<mat-header-row *matHeaderRowDef="['table-title', 'table-details']"></mat-header-row>
<mat-row
*matRowDef="let row; columns: displayedColumns"
[class.old-kot]="row.isOldKot"
[class.new-kot]="row.isNewKot"
[class.is-printed]="row.isPrinted && !row.isHappyHour"
[class.hh-new]="row.isHappyHour && !row.isPrinted"
[class.hh-printed]="row.isPrinted && row.isHappyHour"
*matRowDef="let row; columns: kotColumns; when: isKot"
[class.old-kot]="!!row.id"
[class.new-kot]="!row.id"
></mat-row>
<mat-row
*matRowDef="let row; columns: displayedColumns; when: !isKot"
[class.is-printed]="!!row.kot.id && !row.inv.isHappyHour"
[class.hh-new]="!row.kot.id && row.inv.isHappyHour"
[class.hh-printed]="!!row.kot.id && row.inv.isHappyHour"
></mat-row>
<mat-row
*matRowDef="let row; columns: childColumns; when: isChild"
class="child-row"
[class.is-printed]="!!row.kot.id && !row.inv.isHappyHour"
[class.hh-new]="!row.kot.id && row.inv.isHappyHour"
[class.hh-printed]="!!row.kot.id && row.inv.isHappyHour"
></mat-row>
<mat-footer-row *matFooterRowDef="['gross-title', 'gross-amount']"></mat-footer-row>
<ng-container matColumnDef="gross-title">

View File

@ -1,88 +1,62 @@
@use '@angular/material' as mat
$my-grey: mat.m2-define-palette(mat.$m2-grey-palette)
$my-green: mat.m2-define-palette(mat.$m2-green-palette)
.right-align
display: flex
justify-content: flex-end
table
width: 100%
.mat-column-select
flex: 0 0 60px
.mat-column-selectKot, .mat-column-selectInv, .mat-column-selectChild
flex: 0 0 48px
width: 48px
max-width: 48px
padding-left: 4px
padding-right: 4px
.grey900, .mat-column-amount-title, .mat-column-amount-amount
.mat-column-kotActions, .mat-column-quantity, .mat-column-childQuantity
flex: 0 0 min-content
.mat-column-amount-title, .mat-column-amount-amount
background-color: #1b5e20
color: #ffffff
font-size: 1.2em
// color: mat.m2-get-color-from-palette($my-grey, '900-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 900)
.grey700, .mat-column-tax-title, .mat-column-tax-amount
.mat-column-tax-title, .mat-column-tax-amount
background-color: #388e3c
color: #ffffff
// color: mat.m2-get-color-from-palette($my-grey, '900-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 900)
.grey500, .mat-column-discount-title, .mat-column-discount-amount
.mat-column-discount-title, .mat-column-discount-amount, .mat-column-hh-title, .mat-column-hh-amount
background-color: #4caf50
color: #000000
// color: mat.m2-get-color-from-palette($my-grey, '300-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 300)
.grey300, .mat-column-hh-title, .mat-column-hh-amount, .mat-column-gross-title, .mat-column-gross-amount
.mat-column-gross-title, .mat-column-gross-amount
background-color: #81c784
color: #000000
// color: mat.m2-get-color-from-palette($my-grey, '300-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 300)
.blue400, .old-kot
.old-kot
background-color: #42a5f5
color: #ffffff
// color: mat.m2-get-color-from-palette($my-green, 700)
// background-color: mat.m2-get-color-from-palette($my-green, 500)
.blue800, .new-kot
.new-kot
background-color: #1565c0
color: #ffffff
// color: mat.m2-get-color-from-palette($my-grey, '300-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 300)
.red100, .is-printed
// background-color: #ffcdd2
// color: #000000
// color: mat.m2-get-color-from-palette($my-grey, '300-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 300)
.is-printed
background-color: #ffcdd2
color: #000000
.deep-purple-50
background-color: #ede7f6
color: #000000
// color: mat.m2-get-color-from-palette($my-grey, '300-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 300)
.deep-purple-100
background-color: #d1c4e9
color: #000000
// color: mat.m2-get-color-from-palette($my-grey, '300-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 300)
.deep-purple-200
background-color: #b39ddb
color: #000000
// color: mat.m2-get-color-from-palette($my-grey, '300-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 300)
.yellow300, .hh-new
background-color: #fff176
// color: mat.m2-get-color-from-palette($my-grey, '300-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 300)
.hh-printed
background-color: #f7ca18
// color: mat.m2-get-color-from-palette($my-grey, '300-contrast')
// background: mat.m2-get-color-from-palette($my-grey, 300)
// https://github.com/btxtiger/mat-icon-button-sizes
$button-size: 32px
@ -109,3 +83,8 @@ $icon-size: 19px
.mat-mdc-button-touch-target
width: $button-size !important
height: $button-size !important
.child-row
font: var(--mat-sys-label-small)
letter-spacing: var(--mat-sys-label-small-tracking)
color: color-mix(in srgb, var(--mat-sys-on-surface) 38%, transparent)

View File

@ -1,4 +1,4 @@
import { AsyncPipe, CurrencyPipe } from '@angular/common';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@ -12,10 +12,10 @@ import { Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { AuthService } from '../../auth/auth.service';
import { BillViewItem } from '../../core/bill-view-item';
import { Customer } from '../../core/customer';
import { Table } from '../../core/table';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { MathService } from '../../shared/math.service';
import { TableService } from '../../tables/table.service';
import { BillService } from '../bill.service';
import { ChooseCustomerComponent } from '../choose-customer/choose-customer.component';
@ -23,7 +23,7 @@ import { PaxComponent } from '../pax/pax.component';
import { QuantityComponent } from '../quantity/quantity.component';
import { TablesDialogComponent } from '../tables-dialog/tables-dialog.component';
import { Bill } from './bill';
import { BillSelectionItem } from './bill-selection-item';
import { BillRow } from './bill-row';
import { BillsDataSource } from './bills-datasource';
import { Inventory } from './inventory';
import { Kot } from './kot';
@ -34,7 +34,7 @@ import { VoucherType } from './voucher-type';
templateUrl: './bills.component.html',
styleUrls: ['./bills.component.sass'],
imports: [
AsyncPipe,
CommonModule,
CurrencyPipe,
MatButtonModule,
MatCheckboxModule,
@ -49,13 +49,16 @@ export class BillsComponent implements OnInit {
private router = inject(Router);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private math = inject(MathService);
private auth = inject(AuthService);
bs = inject(BillService);
private tSer = inject(TableService);
dataSource: BillsDataSource = new BillsDataSource(this.bs.dataObs);
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns: string[] = ['select', 'info', 'quantity'];
kotColumns: string[] = ['selectKot', 'infoKot', 'kotActions'];
displayedColumns: string[] = ['selectInv', 'infoInv', 'quantity'];
childColumns: string[] = ['selectChild', 'infoChild', 'childQuantity'];
ngOnInit() {
this.route.data.subscribe((value) => {
@ -102,62 +105,28 @@ export class BillsComponent implements OnInit {
});
}
isAllSelected(kotView: BillViewItem): boolean {
const kot = this.bs.bill.kots.find((k) => k.id === kotView.kotId) as Kot;
isAllSelected(row: BillRow): boolean {
const kot = row.kot as Kot;
return kot.inventories.reduce(
(p: boolean, c: Inventory) =>
p &&
this.bs.selection.isSelected(
JSON.stringify(
new BillSelectionItem({
kotId: kot.id,
inventoryId: c.id,
skuId: c.sku.id,
isHappyHour: c.isHappyHour,
}),
),
),
(p: boolean, c: Inventory) => p && this.bs.selection.isSelected(`${kot.id}-${c.id || c.clientId}`),
true,
);
}
toggle(invView: BillViewItem) {
const key = JSON.stringify(
new BillSelectionItem({
kotId: invView.kotId,
inventoryId: invView.id,
skuId: invView.skuId,
isHappyHour: invView.isHappyHour,
}),
);
this.bs.selection.toggle(key);
toggle(row: BillRow) {
this.bs.selection.toggle(row.id);
}
isSelected(invView: BillViewItem): boolean {
const key = JSON.stringify(
new BillSelectionItem({
kotId: invView.kotId,
inventoryId: invView.id,
skuId: invView.skuId,
isHappyHour: invView.isHappyHour,
}),
);
return this.bs.selection.isSelected(key);
isSelected(row: BillRow): boolean {
return this.bs.selection.isSelected(row.id);
}
isAnySelected(kotView: BillViewItem) {
const kot = this.bs.bill.kots.find((k) => k.id === kotView.kotId) as Kot;
isAnySelected(row: BillRow) {
const kot = row.kot as Kot;
let total = 0;
let found = 0;
for (const item of kot.inventories) {
const key = JSON.stringify(
new BillSelectionItem({
kotId: kot.id,
inventoryId: item.id,
skuId: item.sku.id,
isHappyHour: item.isHappyHour,
}),
);
const key = `${kot.id}-${item.id || item.clientId}`;
total += 1;
if (this.bs.selection.isSelected(key)) {
found += 1;
@ -166,18 +135,11 @@ export class BillsComponent implements OnInit {
return found > 0 && found < total;
}
masterToggle(kotView: BillViewItem) {
const isAllSelected = this.isAllSelected(kotView);
const kot = this.bs.bill.kots.find((k) => k.id === kotView.kotId) as Kot;
masterToggle(row: BillRow) {
const isAllSelected = this.isAllSelected(row);
const kot = row.kot as Kot;
for (const item of kot.inventories) {
const key = JSON.stringify(
new BillSelectionItem({
kotId: kot.id,
inventoryId: item.id,
skuId: item.sku.id,
isHappyHour: item.isHappyHour,
}),
);
const key = `${kot.id}-${item.id || item.clientId}`;
if (isAllSelected) {
this.bs.selection.deselect(key);
} else {
@ -186,21 +148,26 @@ export class BillsComponent implements OnInit {
}
}
trackByKotskuId(index: number, row: BillViewItem): string {
// Fallbacks in case fields are undefined/null
const kotId = row.kotId ?? '';
const skuId = row.skuId ?? '';
return `${kotId}_${skuId}`;
trackByRow = (_: number, row: BillRow) => {
return row.id;
};
isChild = (_: number, row: BillRow) => row.kind === 'child';
isKot = (_: number, row: BillRow) => row.kind === 'kot';
discountPct(row: Inventory): number {
return this.math.halfRoundEven(row.discount * 100, 2);
}
addOne(item: BillViewItem): void {
addOne(item: BillRow): void {
this.bs.addOne(item);
}
quantity(item: BillViewItem): void {
quantity(item: BillRow): void {
const dialogRef = this.dialog.open(QuantityComponent, {
// width: '750px',
data: item.quantity,
data: item.inv?.quantity,
});
dialogRef.afterClosed().subscribe((result: boolean | number) => {
@ -211,12 +178,12 @@ export class BillsComponent implements OnInit {
});
}
subtractOne(item: BillViewItem): void {
subtractOne(item: BillRow): void {
const canEdit = this.auth.allowed('edit-printed-product');
this.bs.subtractOne(item, canEdit);
}
modifier(item: BillViewItem): void {
modifier(item: BillRow): void {
this.bs.modifier(item);
}
@ -257,7 +224,7 @@ export class BillsComponent implements OnInit {
);
}
moveKot(kot: BillViewItem) {
moveKot(kot: BillRow): void {
const canMergeTables = this.auth.allowed('merge-tables');
this.chooseTable(canMergeTables)
.pipe(
@ -270,9 +237,9 @@ export class BillsComponent implements OnInit {
return this.bs.moveTable(table);
}
if (table.status) {
return this.bs.mergeKot(kot.kotId as string, table);
return this.bs.mergeKot(kot.kot.id as string, table);
}
return this.bs.moveKot(kot.kotId as string, table);
return this.bs.moveKot(kot.kot.id as string, table);
}),
)
.subscribe({
@ -286,8 +253,9 @@ export class BillsComponent implements OnInit {
});
}
rowQuantityDisabled(row: BillViewItem) {
if (!row.isPrinted) {
rowQuantityDisabled(row: BillRow): boolean {
const isPrinted = !!(row.inv as Inventory).id;
if (!isPrinted) {
return false;
}
if (this.bs.bill.voucherType === VoucherType.Void) {

View File

@ -1,30 +1,43 @@
import { Modifier } from '../../core/modifier';
import { ProductQuery } from '../../core/product-query';
import { Tax } from '../../core/tax';
import { ProductLink } from './product-link';
export class Inventory {
id: string | undefined;
sku: ProductLink;
clientId: string | undefined;
sku: ProductQuery;
quantity: number;
price: number;
isHappyHour: boolean;
taxRate: number;
type: 'regular' | 'bundle' | 'bundle_item';
parentId?: string;
tax: Tax;
taxRate: number;
discount: number;
modifiers: Modifier[];
sortOrder: number;
children: Inventory[];
public get stableId(): string | undefined {
return this.id ?? this.clientId;
}
public constructor(init?: Partial<Inventory>) {
this.id = undefined;
this.sku = new ProductLink();
if (!this.id) {
this.clientId = crypto.randomUUID();
}
this.sku = new ProductQuery();
this.quantity = 0;
this.price = 0;
this.isHappyHour = false;
this.type = 'regular';
this.taxRate = 0;
this.tax = new Tax();
this.discount = 0;
this.modifiers = [];
this.sortOrder = 0;
this.children = [];
Object.assign(this, init);
}
}

View File

@ -15,7 +15,6 @@ import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dial
import { TableService } from '../../tables/table.service';
import { BillTypeComponent } from '../bill-type/bill-type.component';
import { BillService } from '../bill.service';
import { BillSelectionItem } from '../bills/bill-selection-item';
import { VoucherType } from '../bills/voucher-type';
import { CustomerDiscountsService } from '../discount/customer-discounts.service';
import { DiscountComponent } from '../discount/discount.component';
@ -412,9 +411,8 @@ export class SalesHomeComponent {
}
splitBillWithSelection() {
const inventories: string[] = this.bs.selection.selected.map(
(x: string) => (JSON.parse(x) as BillSelectionItem).inventoryId as string,
);
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const inventories: string[] = this.bs.selection.selected.map((v) => v.match(uuidRegex)?.[0] as string);
return observableOf(inventories);
}
}

View File

@ -6,7 +6,6 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { ProductQuery } from '../../core/product-query';
import { BillService } from '../bill.service';
import { ProductLink } from '../bills/product-link';
@Component({
selector: 'app-products',
@ -31,6 +30,6 @@ export class ProductsComponent implements OnInit {
if (product.isNotAvailable) {
return;
}
this.bs.addSku(new ProductLink(product), 1, 0);
this.bs.addOneSku(product);
}
}

View File

@ -1,7 +1,7 @@
<h2 mat-dialog-title>Tables</h2>
<mat-dialog-content>
<div class="tables-grid">
@for (table of list; track table) {
@for (table of list | async; track table) {
<mat-card
class="flex-col square-button"
matRipple

View File

@ -1,5 +1,5 @@
import { CdkScrollableModule } from '@angular/cdk/scrolling';
import { CurrencyPipe } from '@angular/common';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
@ -13,7 +13,15 @@ import { Table } from '../../core/table';
selector: 'app-tables-dialog',
templateUrl: './tables-dialog.component.html',
styleUrls: ['./tables-dialog.component.sass'],
imports: [CdkScrollableModule, CurrencyPipe, MatButtonModule, MatDialogModule, MatRippleModule, MatCardModule],
imports: [
CdkScrollableModule,
CommonModule,
CurrencyPipe,
MatButtonModule,
MatDialogModule,
MatRippleModule,
MatCardModule,
],
})
export class TablesDialogComponent {
dialogRef = inject<MatDialogRef<TablesDialogComponent>>(MatDialogRef);
@ -22,16 +30,13 @@ export class TablesDialogComponent {
canChooseRunning: boolean;
}>(MAT_DIALOG_DATA);
list: Table[] = [];
list: Observable<Table[]>;
canChooseRunning: boolean;
selected: Table | null;
constructor() {
const data = this.data;
this.data.list.subscribe((list: Table[]) => {
this.list = list;
});
this.list = data.list;
this.canChooseRunning = data.canChooseRunning;
this.selected = null;
}

View File

@ -46,5 +46,9 @@
.center
text-align: center
.right-align
display: flex
justify-content: flex-end
.warn
background-color: red

View File

@ -38,12 +38,12 @@
color: get-on-color($sbsp) !important
background: $sbsp !important
.accent
.square-button.accent
$a: map.get(mat.$orange-palette, 70)
color: get-on-color($a) !important
background: $a !important
.strong-accent
.square-button.strong-accent
$sa: map.get(mat.$orange-palette, 20)
color: get-on-color($sa) !important
background: $sa !important

View File

@ -4,16 +4,11 @@
@use 'square-buttons'
@use 'layout'
// @tailwind base
// @tailwind components
// @tailwind utilities
@include mat.core()
html
:root
// color-scheme: light dark
@include mat.theme((
color: (
@ -23,9 +18,9 @@ html
),
typography: (
plain-family: Montserrat,
brand-family: Montserrat
brand-family: Montserrat,
),
density: 0
density: 0,
))
font-family: "Helvetica Neue", Montserrat, sans-serif
@ -37,16 +32,15 @@ a
color: rgb(0, 0, 238)
text-decoration: underline
.center
text-align: center
.warn
background-color: red
button.mat-primary
.mat-mdc-button.mat-primary,
.mat-mdc-raised-button.mat-primary,
.mat-mdc-unelevated-button.mat-primary
background: var(--mat-sys-primary) !important
color: var(--mat-sys-on-primary) !important
button.mat-warn
.mat-mdc-button.mat-warn,
.mat-mdc-raised-button.mat-warn,
.mat-mdc-unelevated-button.mat-warn
background: var(--mat-sys-error) !important
color: var(--mat-sys-on-error) !important