diff --git a/overlord/src/app/product/product-detail/product-detail.component.ts b/overlord/src/app/product/product-detail/product-detail.component.ts
index f557f944..a55675af 100644
--- a/overlord/src/app/product/product-detail/product-detail.component.ts
+++ b/overlord/src/app/product/product-detail/product-detail.component.ts
@@ -47,7 +47,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
@ViewChild('nameElement', { static: true }) nameElement!: ElementRef
;
form: FormGroup<{
- code: FormControl;
+ handle: FormControl;
name: FormControl;
description: FormControl;
fractionUnits: FormControl;
@@ -89,7 +89,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
constructor() {
this.form = new FormGroup({
- code: new FormControl({ value: 0, disabled: true }, { nonNullable: true }),
+ handle: new FormControl({ value: '', disabled: true }, { nonNullable: true }),
name: new FormControl(null),
description: new FormControl(null),
fractionUnits: new FormControl(null),
@@ -138,7 +138,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
item.productGroup = this.productGroups.find((x) => x.id === item.productGroup?.id);
this.item = item;
this.form.setValue({
- code: this.item.code || '(Auto)',
+ handle: this.item.handle || '',
name: this.item.name,
description: this.item.description || '',
fractionUnits: this.item.fractionUnits ?? '',
diff --git a/overlord/src/app/product/product-list/product-list-datasource.ts b/overlord/src/app/product/product-list/product-list-datasource.ts
index c7ee9382..4d94c5b4 100644
--- a/overlord/src/app/product/product-list/product-list-datasource.ts
+++ b/overlord/src/app/product/product-list/product-list-datasource.ts
@@ -51,9 +51,10 @@ export class ProductListDataSource extends DataSource {
return this.filterValue.split(' ').reduce(
(p: Product[], c: string) =>
p.filter((x) => {
- const productString = `${x.code} ${x.name} ${x.productGroup?.name}${x.isPurchased ? ' purchased' : ' made'}${
- x.isSold ? 'sold' : 'used'
- }${x.isActive ? 'active' : 'deactive'}`.toLowerCase();
+ const productString =
+ `${x.handle} ${x.name} ${x.productGroup?.name}${x.isPurchased ? ' purchased' : ' made'}${
+ x.isSold ? 'sold' : 'used'
+ }${x.isActive ? 'active' : 'deactive'}`.toLowerCase();
return productString.indexOf(c) !== -1;
}),
Object.assign([], data),
diff --git a/overlord/src/app/product/product-list/product-list.component.ts b/overlord/src/app/product/product-list/product-list.component.ts
index 4d2d8474..6b28a23d 100644
--- a/overlord/src/app/product/product-list/product-list.component.ts
+++ b/overlord/src/app/product/product-list/product-list.component.ts
@@ -92,7 +92,7 @@ export class ProductListComponent implements OnInit, AfterViewInit {
exportCsv() {
const headers = {
- Code: 'code',
+ Handle: 'handle',
Name: 'name',
Units: 'units',
Fraction: 'fraction',
diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-product-datasource.ts b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-product-datasource.ts
new file mode 100644
index 00000000..d967e58d
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-product-datasource.ts
@@ -0,0 +1,16 @@
+import { DataSource } from '@angular/cdk/collections';
+import { Observable } from 'rxjs';
+
+import { Product } from '../temporal-product';
+
+export class TemporalProductDetailProductDatasource extends DataSource {
+ constructor(private data: Observable) {
+ super();
+ }
+
+ connect(): Observable {
+ return this.data;
+ }
+
+ disconnect() {}
+}
diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-sku-datasource.ts b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-sku-datasource.ts
new file mode 100644
index 00000000..b6613695
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail-sku-datasource.ts
@@ -0,0 +1,16 @@
+import { DataSource } from '@angular/cdk/collections';
+import { Observable } from 'rxjs';
+
+import { StockKeepingUnit } from '../temporal-product';
+
+export class TemporalProductDetailSkuDatasource extends DataSource {
+ constructor(private data: Observable) {
+ super();
+ }
+
+ connect(): Observable {
+ return this.data;
+ }
+
+ disconnect() {}
+}
diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css
new file mode 100644
index 00000000..8967288e
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css
@@ -0,0 +1,44 @@
+.two-col {
+ display: flex;
+ gap: 16px;
+}
+
+.col {
+ flex: 1;
+ min-width: 0;
+}
+
+.card {
+ padding: 12px;
+ border-radius: 12px;
+ margin-bottom: 12px;
+}
+
+.full-width {
+ width: 100%;
+}
+
+.buttons {
+ justify-content: flex-end;
+ gap: 12px;
+}
+
+.backend-actions {
+ margin-top: 16px;
+ justify-content: flex-end;
+ gap: 12px;
+}
+
+.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 */
+}
diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html
new file mode 100644
index 00000000..936be6b4
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html
@@ -0,0 +1,288 @@
+Product
+
+
+
Product Versions
+
+
+
+ | Name |
+ {{ p.name }} |
+
+
+
+ Fraction Units |
+ {{ p.fractionUnits }} |
+
+
+
+ Product Group |
+ {{ p.productGroup?.name }} |
+
+
+
+ Valid From |
+ {{ p.validFrom }} |
+
+
+
+ Valid Till |
+ {{ p.validTill }} |
+
+
+
+ Actions |
+
+
+
+ |
+
+
+
+
+
+
+
+
SKU Versions
+
+
+
+ | Units |
+ {{ s.units }} |
+
+
+
+
+ Fraction |
+ {{ s.fraction }} |
+
+
+
+ Yield |
+ {{ s.productYield }} |
+
+
+
+ Cost Price |
+ {{ s.costPrice }} |
+
+
+
+ Sale Price |
+ {{ s.salePrice }} |
+
+
+
+ Valid From |
+ {{ s.validFrom }} |
+
+
+
+ Valid Till |
+ {{ s.validTill }} |
+
+
+
+ Actions |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.spec.ts b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.spec.ts
new file mode 100644
index 00000000..0fd07334
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.spec.ts
@@ -0,0 +1,24 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+
+import { TemporalProductDetailComponent } from './temporal-product-detail.component';
+
+describe('TemporalProductDetailComponent', () => {
+ let component: TemporalProductDetailComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [TemporalProductDetailComponent],
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TemporalProductDetailComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts
new file mode 100644
index 00000000..bf801369
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts
@@ -0,0 +1,432 @@
+import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatOptionModule } from '@angular/material/core';
+import { MatDatepickerModule } from '@angular/material/datepicker';
+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 moment from 'moment';
+import { BehaviorSubject } from 'rxjs';
+
+import { ProductGroup } from '../../core/product-group';
+import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
+import { Product, StockKeepingUnit, TemporalProduct } from '../temporal-product';
+import { TemporalProductService } from '../temporal-product.service';
+import { TemporalProductDetailProductDatasource } from './temporal-product-detail-product-datasource';
+import { TemporalProductDetailSkuDatasource } from './temporal-product-detail-sku-datasource';
+
+@Component({
+ selector: 'app-product-detail',
+ templateUrl: './temporal-product-detail.component.html',
+ styleUrls: ['./temporal-product-detail.component.css'],
+ imports: [
+ MatButtonModule,
+ MatCheckboxModule,
+ MatDatepickerModule,
+ MatFormFieldModule,
+ MatIconModule,
+ MatInputModule,
+ MatOptionModule,
+ MatSelectModule,
+ MatTableModule,
+ ReactiveFormsModule,
+ ],
+})
+export class TemporalProductDetailComponent implements OnInit, AfterViewInit {
+ private route = inject(ActivatedRoute);
+ private router = inject(Router);
+ private dialog = inject(MatDialog);
+ private snackBar = inject(MatSnackBar);
+ private ser = inject(TemporalProductService);
+
+ // columns updated to match new Product/StockKeepingUnit properties
+ // include handle and active flag in the table so users can distinguish versions
+ productDisplayedColumns = ['name', 'fractionUnits', 'productGroup', 'validFrom', 'validTill', 'actions'];
+ skuDisplayedColumns = [
+ 'units',
+ 'fraction',
+ 'productYield',
+ 'costPrice',
+ 'salePrice',
+ 'validFrom',
+ 'validTill',
+ 'actions',
+ ];
+
+ @ViewChild('name', { static: true }) nameElement?: ElementRef;
+
+ public selectedProduct: Product | null;
+ public selectedSku: StockKeepingUnit | null;
+
+ productForm: FormGroup<{
+ handle: FormControl;
+ name: FormControl;
+ description: FormControl;
+ fractionUnits: FormControl;
+
+ isPurchased: FormControl;
+ isSold: FormControl;
+ productGroup: FormControl;
+
+ allergen: FormControl;
+ protein: FormControl;
+ carbohydrate: FormControl;
+ totalSugar: FormControl;
+ addedSugar: FormControl;
+ totalFat: FormControl;
+ saturatedFat: FormControl;
+ transFat: FormControl;
+ cholestrol: FormControl;
+ sodium: FormControl;
+
+ msnf: FormControl;
+ otherSolids: FormControl;
+ totalSolids: FormControl;
+ water: FormControl;
+
+ validFrom: FormControl;
+ validTill: FormControl;
+ }>;
+
+ skuForm: FormGroup<{
+ units: FormControl;
+ fraction: FormControl;
+ productYield: FormControl;
+ costPrice: FormControl;
+ salePrice: FormControl;
+ validFrom: FormControl;
+ validTill: FormControl;
+ }>;
+
+ productGroups: ProductGroup[] = [];
+ item: TemporalProduct = new TemporalProduct();
+ public products = new BehaviorSubject([]);
+ public skus = new BehaviorSubject([]);
+ productDataSource: TemporalProductDetailProductDatasource = new TemporalProductDetailProductDatasource(this.products);
+ skuDataSource: TemporalProductDetailSkuDatasource = new TemporalProductDetailSkuDatasource(this.skus);
+
+ constructor() {
+ // Create form and include all additional product properties present on the Product model
+ this.productForm = new FormGroup({
+ handle: new FormControl('', { nonNullable: true }),
+ name: new FormControl('', { nonNullable: true }),
+ description: new FormControl('', { nonNullable: true }),
+ fractionUnits: new FormControl('', { nonNullable: true }),
+
+ isPurchased: new FormControl(true, { nonNullable: true }),
+ isSold: new FormControl(false, { nonNullable: true }),
+ productGroup: new FormControl(new ProductGroup(), { nonNullable: true }),
+
+ allergen: new FormControl('', { nonNullable: true }),
+ protein: new FormControl(0, { nonNullable: true }),
+ carbohydrate: new FormControl(0, { nonNullable: true }),
+ totalSugar: new FormControl(0, { nonNullable: true }),
+ addedSugar: new FormControl(0, { nonNullable: true }),
+ totalFat: new FormControl(0, { nonNullable: true }),
+ saturatedFat: new FormControl(0, { nonNullable: true }),
+ transFat: new FormControl(0, { nonNullable: true }),
+ cholestrol: new FormControl(0, { nonNullable: true }),
+ sodium: new FormControl(0, { nonNullable: true }),
+
+ msnf: new FormControl(0, { nonNullable: true }),
+ otherSolids: new FormControl(0, { nonNullable: true }),
+ totalSolids: new FormControl(0, { nonNullable: true }),
+ water: new FormControl(0, { nonNullable: true }),
+
+ validFrom: new FormControl(null),
+ validTill: new FormControl(null),
+ });
+ this.skuForm = new FormGroup({
+ units: new FormControl('', { nonNullable: true }),
+ fraction: new FormControl(1, { nonNullable: true }),
+ productYield: new FormControl(1, { nonNullable: true }),
+ costPrice: new FormControl(0, { nonNullable: true }),
+ salePrice: new FormControl(0, { nonNullable: true }),
+
+ validFrom: new FormControl(null),
+ validTill: new FormControl(null),
+ });
+ this.selectedProduct = null;
+ this.selectedSku = null;
+ }
+
+ ngOnInit() {
+ this.route.data.subscribe((value) => {
+ const data = value as {
+ item: TemporalProduct;
+ productGroups: ProductGroup[];
+ };
+ this.productGroups = data.productGroups;
+ this.item = data.item;
+ this.skus.next(this.item.skus);
+ this.products.next(this.item.products);
+ });
+ }
+
+ private parseDateOrNull(d: string | null | undefined): Date | null {
+ return !d ? null : moment(d, 'DD-MMM-YYYY').toDate();
+ }
+
+ private formatDateOrNull(d: Date | null | undefined): string | null {
+ return !d ? null : moment(d).format('DD-MMM-YYYY');
+ }
+
+ ngAfterViewInit() {
+ setTimeout(() => {
+ if (this.nameElement !== undefined) {
+ this.nameElement.nativeElement.focus();
+ }
+ }, 0);
+ }
+
+ editProduct(p: Product) {
+ this.selectedProduct = p;
+
+ this.productForm.setValue({
+ handle: p.handle ?? '',
+ name: p.name ?? '',
+ description: p.description ?? '',
+ fractionUnits: p.fractionUnits ?? '',
+
+ isPurchased: p.isPurchased ?? false,
+ isSold: p.isSold ?? false,
+ productGroup: this.productGroups.find((x) => x.id === p.productGroup?.id) ?? new ProductGroup(),
+
+ allergen: p.allergen ?? '',
+ protein: p.protein ?? 0,
+ carbohydrate: p.carbohydrate ?? 0,
+ totalSugar: p.totalSugar ?? 0,
+ addedSugar: p.addedSugar ?? 0,
+ totalFat: p.totalFat ?? 0,
+ saturatedFat: p.saturatedFat ?? 0,
+ transFat: p.transFat ?? 0,
+ cholestrol: p.cholestrol ?? 0,
+ sodium: p.sodium ?? 0,
+
+ msnf: p.msnf ?? 0,
+ otherSolids: p.otherSolids ?? 0,
+ totalSolids: p.totalSolids ?? 0,
+ water: p.water ?? 0,
+
+ validFrom: this.parseDateOrNull(p.validFrom),
+ validTill: this.parseDateOrNull(p.validTill),
+ });
+ setTimeout(() => this.nameElement?.nativeElement?.focus?.(), 0);
+ }
+
+ updateProduct() {
+ if (!this.selectedProduct) {
+ return;
+ }
+ const formModel = this.productForm.value;
+
+ const p = this.selectedProduct;
+
+ p.handle = formModel.handle ?? '';
+ p.name = formModel.name ?? '';
+ p.description = formModel.description ?? '';
+ p.fractionUnits = formModel.fractionUnits ?? '';
+
+ p.isPurchased = formModel.isPurchased ?? false;
+ p.isSold = formModel.isSold ?? false;
+
+ if (p.productGroup === null || p.productGroup === undefined) {
+ p.productGroup = new ProductGroup();
+ }
+ p.productGroup = formModel.productGroup;
+
+ p.allergen = formModel.allergen ?? '';
+ p.protein = formModel.protein ?? 0;
+ p.carbohydrate = formModel.carbohydrate ?? 0;
+ p.totalSugar = formModel.totalSugar ?? 0;
+ p.addedSugar = formModel.addedSugar ?? 0;
+ p.totalFat = formModel.totalFat ?? 0;
+ p.saturatedFat = formModel.saturatedFat ?? 0;
+ p.transFat = formModel.transFat ?? 0;
+ p.cholestrol = formModel.cholestrol ?? 0;
+ p.sodium = formModel.sodium ?? 0;
+
+ p.msnf = formModel.msnf ?? 0;
+ p.otherSolids = formModel.otherSolids ?? 0;
+ p.totalSolids = formModel.totalSolids ?? 0;
+ p.water = formModel.water ?? 0;
+
+ p.validFrom = this.formatDateOrNull(formModel.validFrom);
+ p.validTill = this.formatDateOrNull(formModel.validTill);
+ this.selectedProduct = null;
+
+ // Reset form
+ this.productForm.reset({
+ handle: '',
+ name: '',
+ description: '',
+ fractionUnits: '',
+ isPurchased: true,
+ isSold: false,
+ productGroup: new ProductGroup(),
+ allergen: '',
+ protein: 0,
+ carbohydrate: 0,
+ totalSugar: 0,
+ addedSugar: 0,
+ totalFat: 0,
+ saturatedFat: 0,
+ transFat: 0,
+ cholestrol: 0,
+ sodium: 0,
+ msnf: 0,
+ otherSolids: 0,
+ totalSolids: 0,
+ water: 0,
+ validFrom: null,
+ validTill: null,
+ });
+ }
+
+ deleteProduct(p: Product) {
+ this.item.products.splice(this.item.products.indexOf(p), 1);
+ this.products.next(this.item.products);
+ if (!this.selectedProduct) return;
+
+ const idx = (this.item.products ?? []).indexOf(this.selectedProduct);
+ if (idx >= 0) {
+ this.item.products.splice(idx, 1);
+ }
+
+ this.selectedProduct = null;
+
+ // Reset form
+ this.productForm.reset({
+ handle: '',
+ name: '',
+ description: '',
+ fractionUnits: '',
+ isPurchased: true,
+ isSold: false,
+ productGroup: new ProductGroup(),
+ allergen: '',
+ protein: 0,
+ carbohydrate: 0,
+ totalSugar: 0,
+ addedSugar: 0,
+ totalFat: 0,
+ saturatedFat: 0,
+ transFat: 0,
+ cholestrol: 0,
+ sodium: 0,
+ msnf: 0,
+ otherSolids: 0,
+ totalSolids: 0,
+ water: 0,
+ validFrom: null,
+ validTill: null,
+ });
+ }
+
+ editSku(s: StockKeepingUnit) {
+ this.selectedSku = s;
+
+ this.skuForm.setValue({
+ units: s.units ?? '',
+ fraction: s.fraction ?? 1,
+ productYield: s.productYield ?? 1,
+ costPrice: s.costPrice ?? 0,
+ salePrice: s.salePrice ?? 0,
+ validFrom: this.parseDateOrNull(s.validFrom),
+ validTill: this.parseDateOrNull(s.validTill),
+ });
+ }
+
+ updateSku() {
+ if (!this.selectedSku) return;
+ const formModel = this.skuForm.value;
+
+ const s = this.selectedSku;
+ s.units = formModel.units ?? '';
+ s.fraction = formModel.fraction ?? 1;
+ s.productYield = formModel.productYield ?? 1;
+ s.costPrice = formModel.costPrice ?? 0;
+ s.salePrice = formModel.salePrice ?? 0;
+
+ s.validFrom = this.formatDateOrNull(formModel.validFrom);
+ s.validTill = this.formatDateOrNull(formModel.validTill);
+ this.selectedSku = null;
+ this.skuForm.reset({
+ units: '',
+ fraction: 1,
+ productYield: 1,
+ costPrice: 0,
+ salePrice: 0,
+ validFrom: null,
+ validTill: null,
+ });
+ }
+
+ deleteSku(s: StockKeepingUnit) {
+ this.item.skus.splice(this.item.skus.indexOf(s), 1);
+ this.skus.next(this.item.skus);
+ if (!this.selectedSku) return;
+
+ const idx = (this.item.skus ?? []).indexOf(this.selectedSku);
+ if (idx >= 0) {
+ this.item.skus.splice(idx, 1);
+ }
+
+ this.selectedSku = null;
+
+ // Reset form
+ this.skuForm.reset({
+ units: '',
+ fraction: 1,
+ productYield: 1,
+ costPrice: 0,
+ salePrice: 0,
+ validFrom: null,
+ validTill: null,
+ });
+ }
+
+ update() {
+ this.ser.update(this.item).subscribe({
+ next: () => {
+ this.snackBar.open('', 'Success');
+ this.router.navigateByUrl('/temporal-products');
+ },
+ error: (error) => {
+ this.snackBar.open(error, 'Error');
+ },
+ });
+ }
+
+ delete() {
+ this.ser.delete(this.item.products[0].id as string).subscribe({
+ next: () => {
+ this.snackBar.open('', 'Success');
+ this.router.navigateByUrl('/temporal-products');
+ },
+ error: (error) => {
+ this.snackBar.open(error, 'Error');
+ },
+ });
+ }
+
+ confirmDelete(): void {
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
+ width: '250px',
+ data: { title: 'Delete Product?', content: 'Are you sure? This cannot be undone.' },
+ });
+
+ dialogRef.afterClosed().subscribe((result: boolean) => {
+ if (result) {
+ this.delete();
+ }
+ });
+ }
+}
diff --git a/overlord/src/app/temporal-product/temporal-product-list.resolver.spec.ts b/overlord/src/app/temporal-product/temporal-product-list.resolver.spec.ts
new file mode 100644
index 00000000..250290ad
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-list.resolver.spec.ts
@@ -0,0 +1,18 @@
+import { inject, TestBed } from '@angular/core/testing';
+
+import { TemporalProductListResolverService } from './temporal-product-list-resolver.service';
+
+describe('TemporalProductListResolverService', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [TemporalProductListResolverService],
+ });
+ });
+
+ it('should be created', inject(
+ [TemporalProductListResolverService],
+ (service: TemporalProductListResolverService) => {
+ expect(service).toBeTruthy();
+ },
+ ));
+});
diff --git a/overlord/src/app/temporal-product/temporal-product-list.resolver.ts b/overlord/src/app/temporal-product/temporal-product-list.resolver.ts
new file mode 100644
index 00000000..52e43edd
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-list.resolver.ts
@@ -0,0 +1,9 @@
+import { inject } from '@angular/core';
+import { ResolveFn } from '@angular/router';
+
+import { TemporalProduct } from './temporal-product';
+import { TemporalProductService } from './temporal-product.service';
+
+export const temporalProductListResolver: ResolveFn = () => {
+ return inject(TemporalProductService).list();
+};
diff --git a/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts
new file mode 100644
index 00000000..1b80f6b5
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts
@@ -0,0 +1,81 @@
+import { DataSource } from '@angular/cdk/collections';
+import { merge, Observable } from 'rxjs';
+import { map, tap } from 'rxjs/operators';
+
+import { TemporalProduct } from '../temporal-product';
+
+export class TemporalProductListDatasource extends DataSource {
+ public data: TemporalProduct[];
+ public filteredData: TemporalProduct[];
+ public search: string;
+ public productGroup: string;
+
+ constructor(
+ private readonly searchFilter: Observable,
+ private readonly productGroupFilter: Observable,
+ private readonly dataObs: Observable,
+ ) {
+ super();
+ this.data = [];
+ this.filteredData = [];
+ this.search = '';
+ this.productGroup = '';
+ }
+
+ connect(): Observable {
+ const dataMutations = [
+ this.dataObs.pipe(
+ tap((x) => {
+ this.data = x;
+ }),
+ ),
+ this.searchFilter.pipe(
+ tap((x) => {
+ this.search = x;
+ }),
+ ),
+ this.productGroupFilter.pipe(
+ tap((x) => {
+ this.productGroup = x;
+ }),
+ ),
+ ];
+ return merge(...dataMutations).pipe(
+ map(() => this.getFilteredData(this.data, this.search, this.productGroup)),
+ tap((x: TemporalProduct[]) => {
+ this.filteredData = x;
+ }),
+ );
+ }
+
+ disconnect() {}
+
+ private getFilteredData(data: TemporalProduct[], search: string, productGroup: string): TemporalProduct[] {
+ const tokens = (search ?? '').toLowerCase().split(/\s+/).filter(Boolean);
+
+ return data.filter((tp: TemporalProduct) => {
+ search = search.toLowerCase();
+
+ const products = tp.products ?? [];
+ const skus = tp.skus ?? [];
+
+ // 1) Search: match ANY product/sku fields
+ const matchesSearch =
+ tokens.length === 0 ||
+ tokens.every(
+ (token) =>
+ products.some((p) => {
+ const hay = `${p.name ?? ''} ${p.fractionUnits ?? ''} ${p.productGroup?.name ?? ''}`.toLowerCase();
+ return hay.includes(token);
+ }) ||
+ skus.some((k) => {
+ const hay = `${k.units ?? ''}`.toLowerCase();
+ return hay.includes(token);
+ }),
+ );
+
+ const matchesProductGroup = !productGroup || products.some((k) => (k.productGroup?.id ?? '') === productGroup);
+ return matchesSearch && matchesProductGroup;
+ });
+ }
+}
diff --git a/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.css b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.css
new file mode 100644
index 00000000..17533808
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.css
@@ -0,0 +1,24 @@
+.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-info,
+.mat-column-productGroup {
+ margin-left: 4px;
+ margin-right: 4px;
+}
+
+.mat-column-quantity {
+ margin-left: 4px;
+}
diff --git a/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html
new file mode 100644
index 00000000..450ddcec
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html
@@ -0,0 +1,65 @@
+Temporal Products
+
+
+
+
+ Products
+
+
+
+
+
+
+ Skus
+
+
+ @for (s of row.skus; track s) {
+ -
+ {{ s.units }}
+
+ }
+
+
+
+
+
+
+ Details
+
+
+
+
+
+
diff --git a/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.spec.ts b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.spec.ts
new file mode 100644
index 00000000..05da17e2
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
+
+import { TemporalProductListComponent } from './temporal-product-list.component';
+
+describe('TemporalProductListComponent', () => {
+ let component: TemporalProductListComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(fakeAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [TemporalProductListComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TemporalProductListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }));
+
+ it('should compile', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts
new file mode 100644
index 00000000..2b7e5017
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts
@@ -0,0 +1,86 @@
+import { CommonModule } from '@angular/common';
+import { Component, OnInit, inject } from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+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, RouterLink } from '@angular/router';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
+
+import { ProductGroup } from '../../core/product-group';
+import { TemporalProduct } from '../temporal-product';
+import { TemporalProductListDatasource } from './temporal-product-list-datasource';
+
+@Component({
+ selector: 'app-product-list',
+ templateUrl: './temporal-product-list.component.html',
+ styleUrls: ['./temporal-product-list.component.css'],
+ imports: [
+ CommonModule,
+ MatFormFieldModule,
+ MatIconModule,
+ MatInputModule,
+ MatOptionModule,
+ MatSelectModule,
+ MatTableModule,
+ ReactiveFormsModule,
+ RouterLink,
+ ],
+})
+export class TemporalProductListComponent implements OnInit {
+ private route = inject(ActivatedRoute);
+
+ searchFilter = new Observable();
+ productGroupFilter = new BehaviorSubject('');
+ data: BehaviorSubject = new BehaviorSubject([]);
+ dataSource: TemporalProductListDatasource = new TemporalProductListDatasource(
+ this.searchFilter,
+ this.productGroupFilter,
+ this.data,
+ );
+
+ form: FormGroup<{
+ filter: FormControl;
+ productGroup: FormControl;
+ }>;
+
+ list: TemporalProduct[] = [];
+ productGroups: ProductGroup[] = [];
+ /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
+ displayedColumns: string[] = ['product', 'sku', 'info'];
+
+ constructor() {
+ this.form = new FormGroup({
+ filter: new FormControl('', { nonNullable: true }),
+ productGroup: new FormControl(''),
+ });
+ this.data.subscribe((data: TemporalProduct[]) => {
+ this.list = data;
+ });
+ this.searchFilter = this.form.controls.filter.valueChanges.pipe(debounceTime(150), distinctUntilChanged());
+ }
+
+ filterOn(val: string) {
+ this.productGroupFilter.next(val);
+ }
+
+ ngOnInit() {
+ this.dataSource = new TemporalProductListDatasource(this.searchFilter, this.productGroupFilter, this.data);
+ this.route.data.subscribe((value) => {
+ const data = value as {
+ list: TemporalProduct[];
+ productGroups: ProductGroup[];
+ };
+ this.loadData(data.list, data.productGroups);
+ });
+ }
+
+ loadData(list: TemporalProduct[], productGroups: ProductGroup[]) {
+ this.productGroups = productGroups;
+ this.data.next(list);
+ }
+}
diff --git a/overlord/src/app/temporal-product/temporal-product.resolver.spec.ts b/overlord/src/app/temporal-product/temporal-product.resolver.spec.ts
new file mode 100644
index 00000000..129c4dd0
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product.resolver.spec.ts
@@ -0,0 +1,15 @@
+import { inject, TestBed } from '@angular/core/testing';
+
+import { TemporalProductResolverService } from './temporal-product-resolver.service';
+
+describe('TemporalProductResolverService', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [TemporalProductResolverService],
+ });
+ });
+
+ it('should be created', inject([TemporalProductResolverService], (service: TemporalProductResolverService) => {
+ expect(service).toBeTruthy();
+ }));
+});
diff --git a/overlord/src/app/temporal-product/temporal-product.resolver.ts b/overlord/src/app/temporal-product/temporal-product.resolver.ts
new file mode 100644
index 00000000..7652b7fa
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product.resolver.ts
@@ -0,0 +1,10 @@
+import { inject } from '@angular/core';
+import { ResolveFn } from '@angular/router';
+
+import { TemporalProduct } from './temporal-product';
+import { TemporalProductService } from './temporal-product.service';
+
+export const temporalProductResolver: ResolveFn = (route) => {
+ const id = route.paramMap.get('id');
+ return inject(TemporalProductService).get(id as string);
+};
diff --git a/overlord/src/app/temporal-product/temporal-product.service.spec.ts b/overlord/src/app/temporal-product/temporal-product.service.spec.ts
new file mode 100644
index 00000000..5148aed1
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product.service.spec.ts
@@ -0,0 +1,15 @@
+import { inject, TestBed } from '@angular/core/testing';
+
+import { TemporalProductService } from './temporal-product.service';
+
+describe('TemporalProductService', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [TemporalProductService],
+ });
+ });
+
+ it('should be created', inject([TemporalProductService], (service: TemporalProductService) => {
+ expect(service).toBeTruthy();
+ }));
+});
diff --git a/overlord/src/app/temporal-product/temporal-product.service.ts b/overlord/src/app/temporal-product/temporal-product.service.ts
new file mode 100644
index 00000000..c20808a9
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product.service.ts
@@ -0,0 +1,44 @@
+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 { TemporalProduct } from './temporal-product';
+
+const httpOptions = {
+ headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
+};
+
+const url = '/api/temporal-products';
+const serviceName = 'ProductService';
+
+@Injectable({ providedIn: 'root' })
+export class TemporalProductService {
+ private http = inject(HttpClient);
+ private log = inject(ErrorLoggerService);
+
+ get(id: string): Observable {
+ return this.http
+ .get(`${url}/${id}`)
+ .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable;
+ }
+
+ list(): Observable {
+ return this.http
+ .get(`${url}/list`)
+ .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable;
+ }
+
+ update(product: TemporalProduct): Observable {
+ return this.http
+ .put(`${url}/${product.products[0].id}`, product, httpOptions)
+ .pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable;
+ }
+
+ delete(id: string): Observable {
+ return this.http
+ .delete(`${url}/${id}`, httpOptions)
+ .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable;
+ }
+}
diff --git a/overlord/src/app/temporal-product/temporal-product.ts b/overlord/src/app/temporal-product/temporal-product.ts
new file mode 100644
index 00000000..1a0e1882
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-product.ts
@@ -0,0 +1,102 @@
+import { Account } from '../core/account';
+import { ProductGroup } from '../core/product-group';
+
+export class Product {
+ id: string | undefined;
+ versionId?: string;
+ handle: string;
+ name: string;
+ description: string | undefined;
+ fractionUnits: string;
+
+ isFixture: boolean;
+ isPurchased: boolean;
+ isSold: boolean;
+ productGroup?: ProductGroup;
+ account?: Account;
+
+ allergen: string;
+ protein: number;
+ carbohydrate: number;
+ totalSugar: number;
+ addedSugar: number;
+ totalFat: number;
+ saturatedFat: number;
+ transFat: number;
+ cholestrol: number;
+ sodium: number;
+
+ msnf: number;
+ otherSolids: number;
+ totalSolids: number;
+ water: number;
+
+ validFrom: string | null;
+ validTill: string | null;
+
+ public constructor(init?: Partial) {
+ this.id = undefined;
+ this.handle = '';
+ this.name = '';
+ this.fractionUnits = '';
+
+ this.isFixture = false;
+ this.isPurchased = true;
+ this.isSold = false;
+
+ this.allergen = '';
+ this.protein = 0;
+ this.carbohydrate = 0;
+ this.totalSugar = 0;
+ this.addedSugar = 0;
+ this.totalFat = 0;
+ this.saturatedFat = 0;
+ this.transFat = 0;
+ this.cholestrol = 0;
+ this.sodium = 0;
+
+ this.msnf = 0;
+ this.otherSolids = 0;
+ this.totalSolids = 0;
+ this.water = 0;
+
+ this.validFrom = null;
+ this.validTill = null;
+ Object.assign(this, init);
+ }
+}
+
+export class StockKeepingUnit {
+ id: string | undefined;
+ versionId?: string;
+ units: string;
+ fraction: number;
+ productYield: number;
+ costPrice: number;
+ salePrice: number;
+
+ validFrom: string | null;
+ validTill: string | null;
+
+ public constructor(init?: Partial) {
+ this.units = '';
+ this.fraction = 1;
+ this.productYield = 1;
+ this.costPrice = 0;
+ this.salePrice = 0;
+ this.validFrom = null;
+ this.validTill = null;
+ Object.assign(this, init);
+ }
+}
+
+export class TemporalProduct {
+ products: Product[];
+ skus: StockKeepingUnit[];
+
+ public constructor(init?: Partial) {
+ this.products = [];
+ this.skus = [];
+ Object.assign(this, init);
+ }
+}
diff --git a/overlord/src/app/temporal-product/temporal-products.routes.ts b/overlord/src/app/temporal-product/temporal-products.routes.ts
new file mode 100644
index 00000000..302bb388
--- /dev/null
+++ b/overlord/src/app/temporal-product/temporal-products.routes.ts
@@ -0,0 +1,47 @@
+import { Routes } from '@angular/router';
+
+import { authGuard } from '../auth/auth-guard.service';
+import { productGroupListResolver } from '../product-group/product-group-list.resolver';
+import { TemporalProductDetailComponent } from './temporal-product-detail/temporal-product-detail.component';
+import { temporalProductListResolver } from './temporal-product-list.resolver';
+import { TemporalProductListComponent } from './temporal-product-list/temporal-product-list.component';
+import { temporalProductResolver } from './temporal-product.resolver';
+
+export const routes: Routes = [
+ {
+ path: '',
+ component: TemporalProductListComponent,
+ canActivate: [authGuard],
+ data: {
+ permission: 'Temporal Products',
+ },
+ resolve: {
+ list: temporalProductListResolver,
+ productGroups: productGroupListResolver,
+ },
+ },
+ {
+ path: 'new',
+ component: TemporalProductDetailComponent,
+ canActivate: [authGuard],
+ data: {
+ permission: 'Temporal Products',
+ },
+ resolve: {
+ item: temporalProductResolver,
+ productGroups: productGroupListResolver,
+ },
+ },
+ {
+ path: ':id',
+ component: TemporalProductDetailComponent,
+ canActivate: [authGuard],
+ data: {
+ permission: 'Temporal Products',
+ },
+ resolve: {
+ item: temporalProductResolver,
+ productGroups: productGroupListResolver,
+ },
+ },
+];