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

This commit is contained in:
tanshu committed 2026-01-31 15:16:58 +00:00
1 parent b79af6e1c6
commit 6e8843d3c4
48 files changed
+2139 -130

No files matched your search

@@ -0,0 +1,16 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable } from 'rxjs';
import { BundleItem } from '../bundle';
export class BundleDetailDatasource extends DataSource<BundleItem> {
constructor(private data: Observable<BundleItem[]>) {
super();
}
connect(): Observable<BundleItem[]> {
return this.data;
}
disconnect() {}
}
Whitespace-only changes.
@@ -0,0 +1,32 @@
<h1 mat-dialog-title>Edit Bundle Item</h1>
<div mat-dialog-content>
<form [formGroup]="form">
<mat-form-field class="w-full">
<mat-label>Item</mat-label>
<input matInput type="text" formControlName="product" [matAutocomplete]="autoP" />
<mat-autocomplete #autoP="matAutocomplete" [displayWith]="displayFn" (optionSelected)="productSelected($event)">
@for (p of products$ | async; track p) {
<mat-option [value]="p">{{ p.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<div class="flex flex-row flex-wrap gap-3">
<mat-form-field class="flex-auto">
<mat-label>Quantity</mat-label>
<input matInput type="number" formControlName="quantity" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Sale Price</mat-label>
<input matInput type="number" formControlName="salePrice" />
</mat-form-field>
</div>
</form>
</div>
<div mat-dialog-actions>
<button mat-raised-button color="warn" [mat-dialog-close]="false">Cancel</button>
<button mat-raised-button color="primary" (click)="accept()">Ok</button>
</div>
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BundleDetailDialogComponent } from './bundle-detail-dialog.component';
describe('BundleDetailDialogComponent', () => {
let component: BundleDetailDialogComponent;
let fixture: ComponentFixture<BundleDetailDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BundleDetailDialogComponent],
}).compileComponents();
fixture = TestBed.createComponent(BundleDetailDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,110 @@
import { AsyncPipe } from '@angular/common';
import { Component, OnInit, 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 { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { debounceTime, distinctUntilChanged, Observable, of as observableOf, switchMap } from 'rxjs';
import { ProductQuery } from '../../core/product-query';
import { ProductService } from '../../product/product.service';
import { BundleItem } from '../bundle';
@Component({
selector: 'app-bundle-detail-dialog',
templateUrl: './bundle-detail-dialog.component.html',
styleUrls: ['./bundle-detail-dialog.component.css'],
standalone: true,
imports: [
AsyncPipe,
MatAutocompleteModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
],
})
export class BundleDetailDialogComponent implements OnInit {
private readonly dialogRef = inject<MatDialogRef<BundleDetailDialogComponent>>(MatDialogRef);
private readonly productSer = inject(ProductService);
data = inject<{ item: BundleItem }>(MAT_DIALOG_DATA);
selectedProduct: ProductQuery | null = null;
products$: Observable<ProductQuery[]>;
form: FormGroup<{
product: FormControl<ProductQuery | string>;
quantity: FormControl<number>;
salePrice: FormControl<number>;
}>;
constructor() {
this.form = new FormGroup({
product: new FormControl<ProductQuery | string>('', { nonNullable: true }),
quantity: new FormControl<number>(1, { nonNullable: true }),
salePrice: new FormControl<number>(0, { nonNullable: true }),
});
this.products$ = this.form.controls.product.valueChanges.pipe(
debounceTime(150),
distinctUntilChanged(),
switchMap((x) => {
if (typeof x !== 'string') return observableOf([]);
return x.trim() === '' ? observableOf([]) : this.productSer.query(x);
}),
);
// typing after selecting invalidates prior selection
this.form.controls.product.valueChanges.subscribe((x) => {
if (typeof x === 'string') this.selectedProduct = null;
});
}
ngOnInit(): void {
// preload display text using existing name
this.form.setValue({
product: this.data.item.name ?? '',
quantity: Number(this.data.item.quantity ?? 1),
salePrice: Number(this.data.item.salePrice ?? 0),
});
this.selectedProduct = null;
}
displayFn(product?: ProductQuery | string): string {
return !product ? '' : typeof product === 'string' ? product : product.name;
}
productSelected(event: MatAutocompleteSelectedEvent): void {
const p = event.option.value as ProductQuery;
this.selectedProduct = p;
}
accept(): void {
const v = this.form.value;
const quantity = Number(v.quantity ?? 0);
if (Number.isNaN(quantity) || quantity <= 0) return;
const salePrice = Number(v.salePrice ?? 0);
if (Number.isNaN(salePrice) || salePrice < 0) return;
// If a new product was selected, update itemId + name
if (this.selectedProduct?.id) {
this.data.item.itemId = this.selectedProduct.id;
this.data.item.name = this.selectedProduct.name ?? '';
} else {
// If no selection, don't allow changing SKU silently
// but still allow quantity/price edit
}
this.data.item.quantity = quantity;
this.data.item.salePrice = salePrice;
this.dialogRef.close(this.data.item);
}
}
@@ -0,0 +1,11 @@
.nutrition-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
align-items: stretch;
justify-items: stretch;
}
.nutrition-grid > * {
width: 100%;
box-sizing: border-box; /* helps with padding */
}
@@ -0,0 +1,120 @@
<h2>Bundle</h2>
<form [formGroup]="form">
<div class="row-container">
<mat-form-field class="basis-3-4">
<mat-label>Name</mat-label>
<input matInput #name formControlName="name" />
</mat-form-field>
<mat-form-field class="basis-1-4">
<mat-label>Units</mat-label>
<input matInput formControlName="units" />
</mat-form-field>
</div>
<div class="row-container">
<mat-form-field class="basis-1-4">
<mat-label>Sale Price</mat-label>
<input matInput type="number" formControlName="salePrice" readonly="" />
<mat-hint>Computed from bundle items</mat-hint>
</mat-form-field>
<mat-form-field class="basis-2-4">
<mat-label>Menu Category</mat-label>
<mat-select formControlName="menuCategory">
@for (mc of menuCategories; track mc) {
<mat-option [value]="mc.id">
{{ mc.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="hasHappyHour" class="basis-1-4">Has Happy Hour?</mat-checkbox>
<mat-checkbox formControlName="isNotAvailable" class="basis-1-4">Not Available?</mat-checkbox>
</div>
<h2>Items</h2>
<div formGroupName="addRow" class="nutrition-grid">
<mat-form-field class="flex-auto">
<mat-label>Product</mat-label>
<input
type="text"
matInput
formControlName="itemId"
[matAutocomplete]="autoItem"
[value]="displayFn(form.controls.addRow.controls.itemId.value)"
/>
<mat-autocomplete #autoItem="matAutocomplete" [displayWith]="displayFn" (optionSelected)="bundleSelected($event)">
@for (p of itemProducts | async; track p) {
<mat-option [value]="p">{{ p.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Quantity</mat-label>
<input matInput type="number" formControlName="quantity" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Sale Price</mat-label>
<input matInput type="number" formControlName="salePrice" />
</mat-form-field>
<button mat-raised-button color="primary" (click)="addRow()" class="flex-auto">Add</button>
</div>
</form>
<div class="row-container wrapped">
<mat-table [dataSource]="dataSource" aria-label="Bundle Items" class="flex-auto">
<!-- Name Column (name is "name (units)") -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Item</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.name }}
</mat-cell>
</ng-container>
<!-- Quantity Column -->
<ng-container matColumnDef="quantity">
<mat-header-cell *matHeaderCellDef class="right">Qty</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">
{{ row.quantity }}
</mat-cell>
</ng-container>
<!-- Sale Price Column -->
<ng-container matColumnDef="salePrice">
<mat-header-cell *matHeaderCellDef class="right">Sale Price</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">
{{ row.salePrice | currency: 'INR' }}
</mat-cell>
</ng-container>
<!-- Action Column -->
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef class="center">Action</mat-header-cell>
<mat-cell *matCellDef="let row" class="center">
<button mat-icon-button tabindex="-1" (click)="editRow(row)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button tabindex="-1" color="warn" (click)="deleteRow(row)">
<mat-icon>delete</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
</div>
<div class="row-container">
<button mat-raised-button color="primary" (click)="save()">Save</button>
@if (!!item.id) {
<button mat-raised-button color="warn" (click)="confirmDelete()">Delete</button>
}
</div>
@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { BundleDetailComponent } from './bundle-detail.component';
describe('BundleDetailComponent', () => {
let component: BundleDetailComponent;
let fixture: ComponentFixture<BundleDetailComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [BundleDetailComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BundleDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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;
}
}