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;
}
}
@@ -0,0 +1,15 @@
import { inject, TestBed } from '@angular/core/testing';
import { BundleListResolver } from './bundle-list-resolver.service';
describe('BundleListResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [BundleListResolver],
});
});
it('should be created', inject([BundleListResolver], (service: BundleListResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Bundle } from './bundle';
import { BundleService } from './bundle.service';
export const bundleListResolver: ResolveFn<Bundle[]> = () => {
return inject(BundleService).list();
};
@@ -0,0 +1,67 @@
import { DataSource } from '@angular/cdk/collections';
import { merge, Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import { Bundle } from '../bundle';
export class BundleListDataSource extends DataSource<Bundle> {
public data: Bundle[];
public filteredData: Bundle[];
public search: string;
public menuCategory: string;
constructor(
private readonly searchFilter: Observable<string>,
private readonly menuCategoryFilter: Observable<string>,
private readonly dataObs: Observable<Bundle[]>,
) {
super();
this.data = [];
this.filteredData = [];
this.search = '';
this.menuCategory = '';
}
connect(): Observable<Bundle[]> {
const dataMutations = [
this.dataObs.pipe(
tap((x) => {
this.data = x;
}),
),
this.searchFilter.pipe(
tap((x) => {
this.search = x;
}),
),
this.menuCategoryFilter.pipe(
tap((x) => {
this.menuCategory = x;
}),
),
];
return merge(...dataMutations).pipe(
map(() => this.getFilteredData(this.data, this.search, this.menuCategory)),
tap((x) => {
this.filteredData = x;
}),
);
}
disconnect() {}
private getFilteredData(data: Bundle[], search: string, menuCategory: string): Bundle[] {
const tokens = (search ?? '').toLowerCase().split(/\s+/).filter(Boolean);
return data.filter((b: Bundle) => {
const mcName = b.menuCategory?.name ?? '';
const hay = `${b.name ?? ''} ${b.units ?? ''} ${mcName}`.toLowerCase();
const matchesSearch = tokens.length === 0 || tokens.every((t) => hay.includes(t));
const matchesMenuCategory = menuCategory === '' || (b.menuCategory?.id ?? '') === menuCategory;
return matchesSearch && matchesMenuCategory;
});
}
}
@@ -0,0 +1,35 @@
.right {
display: flex;
justify-content: flex-end;
}
.material-icons {
vertical-align: middle;
}
.mat-column-name {
margin-right: 4px;
}
.mat-column-price,
.mat-column-menuCategory,
.mat-column-saleCategory,
.mat-column-info {
margin-left: 4px;
margin-right: 4px;
}
.mat-column-quantity {
margin-left: 4px;
}
.items-list {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.item-name {
white-space: nowrap;
}
@@ -0,0 +1,94 @@
<h2 class="row-container space-between">
<span>Bundles</span>
<button mat-icon-button (click)="exportCsv()" [disabled]="!(data | async)?.length">
<mat-icon>save_alt</mat-icon>
</button>
<a mat-button [routerLink]="['/bundles', 'new']">
<mat-icon>add_box</mat-icon>
Add
</a>
</h2>
<form [formGroup]="form" class="flex-col">
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Filter</mat-label>
<input type="text" matInput formControlName="filter" autocomplete="off" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Menu Category</mat-label>
<mat-select formControlName="menuCategory" (selectionChange)="filterOn($event.value)">
<mat-option>-- All Bundles --</mat-option>
@for (mc of menuCategories; track mc) {
<mat-option [value]="mc.id">
{{ mc.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</form>
<mat-table #table [dataSource]="dataSource" aria-label="Bundles">
<!-- Name -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
<mat-cell *matCellDef="let row">
<a [routerLink]="['/bundles', row.id]">{{ row.name }} ({{ row.units }})</a>
</mat-cell>
</ng-container>
<!-- Price -->
<ng-container matColumnDef="price">
<mat-header-cell *matHeaderCellDef class="right">Price</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">
{{ row.salePrice | currency: 'INR' }}
</mat-cell>
</ng-container>
<!-- Menu Category -->
<ng-container matColumnDef="menuCategory">
<mat-header-cell *matHeaderCellDef>Menu Category</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.menuCategory?.name }}
</mat-cell>
</ng-container>
<!-- Items -->
<ng-container matColumnDef="items">
<mat-header-cell *matHeaderCellDef>Items</mat-header-cell>
<mat-cell *matCellDef="let row">
<div class="items-list">
@for (item of row.items; track item) {
<div class="item-name">{{ item.name }} x {{ item.quantity }} @ {{ item.salePrice | currency: 'INR' }}</div>
}
</div>
</mat-cell>
</ng-container>
<!-- Info -->
<ng-container matColumnDef="info">
<mat-header-cell *matHeaderCellDef>Details</mat-header-cell>
<mat-cell *matCellDef="let row">
<div class="row-container">
<div>
<mat-icon>
{{ row.hasHappyHour ? 'sentiment_satisfied_alt' : 'sentiment_dissatisfied' }}
</mat-icon>
<b>{{ row.hasHappyHour ? 'Happy Hour' : 'Regular' }}</b>
</div>
<div>
<mat-icon>
{{ row.isNotAvailable ? 'pause' : 'play_arrow' }}
</mat-icon>
<b>{{ row.isNotAvailable ? 'Not Available' : 'Available' }}</b>
</div>
</div>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
@@ -0,0 +1,22 @@
import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
import { BundleListComponent } from './bundle-list.component';
describe('BundleListComponent', () => {
let component: BundleListComponent;
let fixture: ComponentFixture<BundleListComponent>;
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
imports: [BundleListComponent],
}).compileComponents();
fixture = TestBed.createComponent(BundleListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should compile', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,117 @@
import { AsyncPipe, CurrencyPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core';
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 { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { MenuCategory } from '../../core/menu-category';
import { ToCsvService } from '../../shared/to-csv.service';
import { Bundle } from '../bundle';
import { BundleListDataSource } from './bundle-list-datasource';
@Component({
selector: 'app-bundle-list',
templateUrl: './bundle-list.component.html',
styleUrls: ['./bundle-list.component.css'],
imports: [
AsyncPipe,
CurrencyPipe,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
MatTableModule,
ReactiveFormsModule,
RouterModule,
],
})
export class BundleListComponent implements OnInit {
private route = inject(ActivatedRoute);
private toCsv = inject(ToCsvService);
searchFilter = new BehaviorSubject<string>('');
menuCategoryFilter = new BehaviorSubject<string>('');
data: BehaviorSubject<Bundle[]> = new BehaviorSubject<Bundle[]>([]);
dataSource: BundleListDataSource = new BundleListDataSource(this.searchFilter, this.menuCategoryFilter, this.data);
form: FormGroup<{
menuCategory: FormControl<string | null>;
filter: FormControl<string>;
}>;
list: Bundle[] = [];
menuCategories: MenuCategory[] = [];
displayedColumns: string[] = ['name', 'price', 'menuCategory', 'info', 'items'];
constructor() {
this.form = new FormGroup({
menuCategory: new FormControl<string | null>(''),
filter: new FormControl<string>('', { nonNullable: true }),
});
this.data.subscribe((data: Bundle[]) => {
this.list = data;
});
this.form.controls.filter.valueChanges.pipe(debounceTime(150), distinctUntilChanged()).subscribe((value) => {
this.searchFilter.next(value ?? '');
});
}
filterOn(val: string) {
this.menuCategoryFilter.next(val);
}
ngOnInit() {
this.dataSource = new BundleListDataSource(this.searchFilter, this.menuCategoryFilter, this.data);
this.route.data.subscribe((value) => {
const data = value as { list: Bundle[]; menuCategories: MenuCategory[] };
this.loadData(data.list, data.menuCategories);
});
}
loadData(list: Bundle[], menuCategories: MenuCategory[]) {
this.menuCategories = menuCategories;
this.data.next(list);
}
exportCsv() {
const headers = {
Name: 'name',
Units: 'units',
Price: 'salePrice',
MenuCategory: 'menuCategory',
Items: 'items',
};
// Map items to count for csv (simple)
const csvList = this.dataSource.filteredData.map((b) => ({
...b,
menuCategory: b.menuCategory?.name ?? '',
items: (b.items ?? [])
.map((i) => `${i.name}${i.quantity && i.quantity !== 1 ? ` x${i.quantity}` : ''}`)
.join(' | '),
}));
const csvData = new Blob([this.toCsv.toCsv(headers, csvList as unknown as Bundle[])], {
type: 'text/csv;charset=utf-8;',
});
const link = document.createElement('a');
link.href = window.URL.createObjectURL(csvData);
link.setAttribute('download', 'bundles.csv');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
@@ -0,0 +1,15 @@
import { inject, TestBed } from '@angular/core/testing';
import { BundleResolver } from './bundle-resolver.service';
describe('BundleResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [BundleResolver],
});
});
it('should be created', inject([BundleResolver], (service: BundleResolver) => {
expect(service).toBeTruthy();
}));
});
+10
View File
@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router';
import { Bundle } from './bundle';
import { BundleService } from './bundle.service';
export const bundleResolver: ResolveFn<Bundle> = (route: ActivatedRouteSnapshot) => {
const id = route.paramMap.get('id');
return inject(BundleService).get(id);
};
@@ -0,0 +1,15 @@
import { inject, TestBed } from '@angular/core/testing';
import { BundleService } from './bundle.service';
describe('BundleService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [BundleService],
});
});
it('should be created', inject([BundleService], (service: BundleService) => {
expect(service).toBeTruthy();
}));
});
+58
View File
@@ -0,0 +1,58 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ErrorLoggerService } from '../core/error-logger.service';
import { Bundle } from './bundle';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
};
const url = '/api/bundles';
const serviceName = 'BundleService';
@Injectable({ providedIn: 'root' })
export class BundleService {
private http = inject(HttpClient);
private log = inject(ErrorLoggerService);
list(): Observable<Bundle[]> {
return this.http
.get<Bundle[]>(`${url}/list`)
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<Bundle[]>;
}
get(id: string | null): Observable<Bundle> {
const getUrl: string = id === null ? `${url}` : `${url}/${id}`;
return this.http
.get<Bundle>(getUrl)
.pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<Bundle>;
}
save(bundle: Bundle): Observable<void> {
return this.http
.post<Bundle>(`${url}`, bundle, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable<void>;
}
update(bundle: Bundle): Observable<void> {
return this.http
.put<Bundle>(`${url}/${bundle.id}`, bundle, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable<void>;
}
saveOrUpdate(bundle: Bundle): Observable<void> {
if (!bundle.versionId) {
return this.save(bundle);
}
return this.update(bundle);
}
delete(id: string): Observable<void> {
return this.http
.delete<Bundle>(`${url}/${id}`, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable<void>;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { MenuCategory } from '../core/menu-category';
export class BundleItem {
id?: string;
itemId: string;
name: string;
salePrice: number;
quantity: number;
public constructor(init?: Partial<BundleItem>) {
this.itemId = '';
this.name = '';
this.salePrice = 0;
this.quantity = 0;
Object.assign(this, init);
}
}
export class Bundle {
// backend: id_ (sku id), version_id (skuVersion id)
id?: string;
versionId?: string;
name: string;
units: string;
salePrice: number;
hasHappyHour: boolean;
isNotAvailable: boolean;
sortOrder: number;
menuCategory?: MenuCategory | null;
items: BundleItem[];
public constructor(init?: Partial<Bundle>) {
this.name = '';
this.units = '';
this.salePrice = 0;
this.hasHappyHour = false;
this.isNotAvailable = false;
this.sortOrder = 0;
this.menuCategory = null;
this.items = [];
Object.assign(this, init);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service';
import { menuCategoryListResolver } from '../menu-category/menu-category-list.resolver';
import { BundleDetailComponent } from './bundle-detail/bundle-detail.component';
import { bundleListResolver } from './bundle-list.resolver';
import { BundleListComponent } from './bundle-list/bundle-list.component';
import { bundleResolver } from './bundle.resolver';
export const routes: Routes = [
{
path: '',
component: BundleListComponent,
canActivate: [authGuard],
data: { permission: 'Products' }, // change if you have a separate permission
resolve: {
list: bundleListResolver,
menuCategories: menuCategoryListResolver,
},
},
{
path: 'new',
component: BundleDetailComponent,
canActivate: [authGuard],
data: { permission: 'Products' },
resolve: {
item: bundleResolver, // returns new Bundle() because id === null (no param here)
menuCategories: menuCategoryListResolver,
},
},
{
path: ':id',
component: BundleDetailComponent,
canActivate: [authGuard],
data: { permission: 'Products' },
resolve: {
item: bundleResolver,
menuCategories: menuCategoryListResolver,
},
},
];