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 { SaleCategory } from '../../core/sale-category'; import { ProductService } from '../../product/product.service'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { Bundle, BundleItem } from '../bundle'; import { BundleService } from '../bundle.service'; import { BundleDetailDatasource } from './bundle-detail-datasource'; import { BundleDetailDialogComponent } from './bundle-detail-dialog.component'; @Component({ selector: 'app-bundle-detail', templateUrl: './bundle-detail.component.html', styleUrls: ['./bundle-detail.component.css'], imports: [ AsyncPipe, CurrencyPipe, MatAutocompleteModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatOptionModule, MatSelectModule, MatTableModule, ReactiveFormsModule, ], }) export class BundleDetailComponent implements OnInit, AfterViewInit { private route = inject(ActivatedRoute); private router = inject(Router); private dialog = inject(MatDialog); private snackBar = inject(MatSnackBar); private ser = inject(BundleService); private productSer = inject(ProductService); @ViewChild('name', { static: true }) nameElement?: ElementRef; form: FormGroup<{ // Bundle header fields name: FormControl; units: FormControl; salePrice: FormControl; menuCategory: FormControl; saleCategory: FormControl; hasHappyHour: FormControl; isNotAvailable: FormControl; // Item add row addRow: FormGroup<{ itemId: FormControl; quantity: FormControl; salePrice: FormControl; printInBill: FormControl; }>; }>; menuCategories: MenuCategory[] = []; saleCategories: SaleCategory[] = []; public items$ = new BehaviorSubject([]); dataSource: BundleDetailDatasource = new BundleDetailDatasource(this.items$); item: Bundle = new Bundle(); itemProduct: ProductQuery | null = null; itemProducts: Observable; displayedColumns = ['name', 'quantity', 'salePrice', 'printInBill', 'action']; constructor() { this.form = new FormGroup({ name: new FormControl('', { nonNullable: true }), units: new FormControl('', { nonNullable: true }), salePrice: new FormControl(0, { nonNullable: true }), menuCategory: new FormControl('', { nonNullable: true }), saleCategory: new FormControl('', { nonNullable: true }), hasHappyHour: new FormControl(false, { nonNullable: true }), isNotAvailable: new FormControl(false, { nonNullable: true }), addRow: new FormGroup({ itemId: new FormControl('', { nonNullable: true }), quantity: new FormControl(1, { nonNullable: true }), salePrice: new FormControl(0, { nonNullable: true }), printInBill: new FormControl(true, { 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[]; saleCategories: SaleCategory[]; }; this.menuCategories = data.menuCategories; this.saleCategories = data.saleCategories; 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 ?? '', saleCategory: this.item.saleCategory?.id ?? '', hasHappyHour: this.item.hasHappyHour ?? false, isNotAvailable: this.item.isNotAvailable ?? false, addRow: { itemId: '', quantity: 1, salePrice: 0, printInBill: true, }, }); 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, printInBill: v.printInBill || false, }); 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; // sale category const saleCategoryId = v.saleCategory ?? ''; if (!saleCategoryId) { // 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.saleCategory === null || this.item.saleCategory === undefined) { this.item.saleCategory = new SaleCategory(); } this.item.saleCategory.id = saleCategoryId; // ensure items array exists if (!this.item.items) { this.item.items = []; } return this.item; } }