Feature: Added product bundles. Have to wire it up to inventory and reports.

This commit is contained in:
2026-01-31 15:16:58 +00:00
parent b79af6e1c6
commit 6e8843d3c4
48 changed files with 2139 additions and 130 deletions

View File

@ -0,0 +1,325 @@
import { AsyncPipe, CurrencyPipe } from '@angular/common';
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatOptionModule } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { of as observableOf, BehaviorSubject, debounceTime, distinctUntilChanged, Observable, switchMap } from 'rxjs';
import { MenuCategory } from '../../core/menu-category';
import { ProductQuery } from '../../core/product-query';
import { ProductService } from '../../product/product.service';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { Bundle, BundleItem } from '../bundle';
import { BundleService } from '../bundle.service';
import { BundleDetailDatasource } from './bundle-detail-datasource';
import { BundleDetailDialogComponent } from './bundle-detail-dialog.component';
@Component({
selector: 'app-bundle-detail',
templateUrl: './bundle-detail.component.html',
styleUrls: ['./bundle-detail.component.css'],
imports: [
AsyncPipe,
CurrencyPipe,
MatAutocompleteModule,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
MatTableModule,
ReactiveFormsModule,
],
})
export class BundleDetailComponent implements OnInit, AfterViewInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private ser = inject(BundleService);
private productSer = inject(ProductService);
@ViewChild('name', { static: true }) nameElement?: ElementRef;
form: FormGroup<{
// Bundle header fields
name: FormControl<string>;
units: FormControl<string>;
salePrice: FormControl<number>;
menuCategory: FormControl<string>;
hasHappyHour: FormControl<boolean>;
isNotAvailable: FormControl<boolean>;
// Item add row
addRow: FormGroup<{
itemId: FormControl<ProductQuery | string>;
quantity: FormControl<number>;
salePrice: FormControl<number>;
}>;
}>;
menuCategories: MenuCategory[] = [];
public items$ = new BehaviorSubject<BundleItem[]>([]);
dataSource: BundleDetailDatasource = new BundleDetailDatasource(this.items$);
item: Bundle = new Bundle();
itemProduct: ProductQuery | null = null;
itemProducts: Observable<ProductQuery[]>;
displayedColumns = ['name', 'quantity', 'salePrice', 'action'];
constructor() {
this.form = new FormGroup({
name: new FormControl<string>('', { nonNullable: true }),
units: new FormControl<string>('', { nonNullable: true }),
salePrice: new FormControl<number>(0, { nonNullable: true }),
menuCategory: new FormControl<string>('', { nonNullable: true }),
hasHappyHour: new FormControl<boolean>(false, { nonNullable: true }),
isNotAvailable: new FormControl<boolean>(false, { nonNullable: true }),
addRow: new FormGroup({
itemId: new FormControl<ProductQuery | string>('', { nonNullable: true }),
quantity: new FormControl<number>(1, { nonNullable: true }),
salePrice: new FormControl<number>(0, { nonNullable: true }),
}),
});
this.itemProducts = this.form.controls.addRow.controls.itemId.valueChanges.pipe(
debounceTime(150),
distinctUntilChanged(),
switchMap((x) => {
// if user types, x is string; if user selects, x is Product
if (typeof x !== 'string') {
return observableOf([]);
}
return x.trim() === '' ? observableOf([]) : this.productSer.query(x);
}),
);
this.form.controls.addRow.controls.itemId.valueChanges.subscribe((x) => {
if (typeof x === 'string') {
this.itemProduct = null;
}
});
}
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as {
item: Bundle;
menuCategories: MenuCategory[];
};
this.menuCategories = data.menuCategories;
this.showItem(data.item);
});
}
displayFn(product?: ProductQuery | string): string {
return !product ? '' : typeof product === 'string' ? product : product.name;
}
bundleSelected(event: MatAutocompleteSelectedEvent): void {
const product = event.option.value as ProductQuery;
this.itemProduct = product;
this.form.controls.addRow.controls.salePrice.setValue(product.price ?? 0);
}
private recalcBundleSalePrice(): void {
const total = (this.item.items ?? []).reduce((sum, x) => {
const qty = Number(x.quantity ?? 0);
const price = Number(x.salePrice ?? 0);
if (Number.isNaN(qty) || Number.isNaN(price)) return sum;
return sum + qty * price;
}, 0);
// keep model + form in sync
this.item.salePrice = Number(total.toFixed(2));
// if the control is disabled, use patchValue
this.form.controls.salePrice.patchValue(this.item.salePrice);
}
showItem(item: Bundle) {
this.item = item;
this.form.setValue({
name: this.item.name ?? '',
units: this.item.units ?? '',
salePrice: Number(this.item.salePrice ?? 0),
menuCategory: this.item.menuCategory?.id ?? '',
hasHappyHour: this.item.hasHappyHour ?? false,
isNotAvailable: this.item.isNotAvailable ?? false,
addRow: {
itemId: '',
quantity: 1,
salePrice: 0,
},
});
this.itemProduct = null;
this.form.controls.salePrice.disable({ emitEvent: false });
this.items$.next(this.item.items ?? []);
this.recalcBundleSalePrice();
}
ngAfterViewInit() {
setTimeout(() => {
if (this.nameElement !== undefined) {
this.nameElement.nativeElement.focus();
}
}, 0);
}
resetAddRow() {
this.form.controls.addRow.reset();
this.itemProduct = null;
}
addRow() {
const v = this.form.value.addRow;
if (!v) {
return;
}
if (!this.itemProduct?.id) {
this.snackBar.open('Please select a product', 'Error');
return;
}
const quantity = Number(v.quantity ?? 0);
if (Number.isNaN(quantity) || quantity <= 0) {
this.snackBar.open('Quantity has to be > 0', 'Error');
return;
}
const salePrice = Number(v.salePrice ?? 0);
if (Number.isNaN(salePrice) || salePrice < 0) {
this.snackBar.open('Sale Price has to be >= 0', 'Error');
return;
}
// name is expected from backend as "name (units)" on GET;
// for new rows we keep it blank (or you can set placeholder)
const bi = new BundleItem({
itemId: this.itemProduct?.id,
name: this.itemProduct?.name ?? '',
quantity,
salePrice,
});
this.item.items.push(bi);
this.items$.next(this.item.items);
this.resetAddRow();
this.recalcBundleSalePrice();
}
editRow(row: BundleItem) {
const dialogRef = this.dialog.open(BundleDetailDialogComponent, {
width: '650px',
data: {
item: JSON.parse(JSON.stringify(row)) as BundleItem,
},
});
dialogRef.afterClosed().subscribe((result: boolean | BundleItem) => {
if (!result) {
return;
}
Object.assign(row, result as BundleItem);
this.items$.next(this.item.items);
this.resetAddRow();
this.recalcBundleSalePrice();
});
}
deleteRow(row: BundleItem) {
this.item.items.splice(this.item.items.indexOf(row), 1);
this.items$.next(this.item.items);
this.recalcBundleSalePrice();
}
save() {
if (!this.item.items || this.item.items.length === 0) {
this.snackBar.open('Bundle must contain at least one item', 'Error');
return;
}
this.ser.saveOrUpdate(this.getItem()).subscribe({
next: () => {
this.snackBar.open('', 'Success');
this.router.navigateByUrl('/bundles');
},
error: (error) => {
this.snackBar.open(error, 'Error');
},
});
}
delete() {
this.ser.delete(this.item.id as string).subscribe({
next: () => {
this.snackBar.open('', 'Success');
this.router.navigateByUrl('/bundles');
},
error: (error) => {
this.snackBar.open(error, 'Error');
},
});
}
confirmDelete(): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
width: '250px',
data: { title: 'Delete Bundle?', content: 'Are you sure? This cannot be undone.' },
});
dialogRef.afterClosed().subscribe((result: boolean) => {
if (result) {
this.delete();
}
});
}
getItem(): Bundle {
const v = this.form.value;
this.item.name = v.name ?? '';
this.item.units = v.units ?? '';
this.item.salePrice = Number(v.salePrice ?? 0);
this.item.hasHappyHour = v.hasHappyHour ?? false;
this.item.isNotAvailable = v.isNotAvailable ?? false;
this.item.sortOrder = this.item.sortOrder ?? 0;
// menu category
const menuCategoryId = v.menuCategory ?? '';
if (!menuCategoryId) {
// keep it as-is; backend will 422 anyway, but we can show UI error too
this.snackBar.open('Menu Category is required', 'Error');
return this.item;
}
if (this.item.menuCategory === null || this.item.menuCategory === undefined) {
this.item.menuCategory = new MenuCategory();
}
this.item.menuCategory.id = menuCategoryId;
// ensure items array exists
if (!this.item.items) {
this.item.items = [];
}
return this.item;
}
}