Feature: Added product bundles. Have to wire it up to inventory and reports.
This commit is contained in:
325
bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts
Normal file
325
bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user